@@ -38,6 +41,9 @@ SelectProjectTemplate.propTypes = {
projectTemplates: PropTypes.array.isRequired,
selectedTemplate: PropTypes.object,
onSelectProjectTemplate: PropTypes.func.isRequired,
+ onEditTemplate: PropTypes.func,
+ onExportTemplate: PropTypes.func,
+ onDeleteTemplate: PropTypes.func,
};
export default SelectProjectTemplate;
diff --git a/app/constants/assets-config.js b/app/constants/assets-config.js
index d33a6015..37992e93 100644
--- a/app/constants/assets-config.js
+++ b/app/constants/assets-config.js
@@ -205,4 +205,5 @@ module.exports = {
// patterns: [/\.md$/i]
// }
],
+ CUSTOM_ATTRIBUTE_NAME_MAX_LENGTH: 100,
};
diff --git a/app/constants/constants.js b/app/constants/constants.js
index 2cd51914..cd38998b 100644
--- a/app/constants/constants.js
+++ b/app/constants/constants.js
@@ -45,7 +45,8 @@ module.exports = {
LOG: '.statwrap.log',
CHECKLIST: '.statwrap-checklist.json',
CLONED_PROJECT_MARKER: 'cloned_project_marker',
- SEARCH_INDEX: 'search-index.json'
+ SEARCH_INDEX: 'search-index.json',
+ CUSTOM_PROJECT_TEMPLATES: 'custom-project-templates',
},
ActionType: {
@@ -115,6 +116,11 @@ module.exports = {
},
MAX_GRAPH_LABEL_LENGTH: 31,
+ CHECKLIST_NAME_MAX_LENGTH: 250,
+ CHECKLIST_DESCRIPTION_MAX_LENGTH: 1000,
+ CHECKLIST_IMPORT_MAX_FILE_SIZE: 1*1024*1024, // 1 MB
+ CHECKLIST_EXPORT_TYPE: 'statwrap-checklist',
+ CHECKLIST_EXPORT_VERSION: 1,
CHECKLIST: [
['Dependency', 'Software dependencies for the project are documented.'],
@@ -124,4 +130,43 @@ module.exports = {
['VersionControl', 'Version control of some kind is in place.'],
['AbsolutePaths', 'Avoids using absolute paths in the code.'],
],
+
+ CHECKLIST_DEFAULTS: [
+ {
+ name: 'Dependency',
+ statement: 'Software dependencies for the project are documented.',
+ description: '',
+ scankey: 'Dependency'
+ },
+ {
+ name: 'Data',
+ statement: 'Data file(s) used in the project are documented.',
+ description: '',
+ scankey: 'Data'
+ },
+ {
+ name: 'Entrypoint',
+ statement: 'Indication of file(s) that are used to run the analysis (e.g., wrapper/entry script).',
+ description: '',
+ scankey: 'Entrypoint'
+ },
+ {
+ name: 'Documentation',
+ statement: 'Includes project documentation.',
+ description: '',
+ scankey: 'Documentation'
+ },
+ {
+ name: 'VersionControl',
+ statement: 'Version control of some kind is in place.',
+ description: '',
+ scankey: 'VersionControl'
+ },
+ {
+ name: 'AbsolutePaths',
+ statement: 'Avoids using absolute paths in the code.',
+ description: '',
+ scankey: 'AbsolutePaths',
+ },
+ ],
};
diff --git a/app/constants/messages.js b/app/constants/messages.js
index d8b75fbe..641b7b0c 100644
--- a/app/constants/messages.js
+++ b/app/constants/messages.js
@@ -10,6 +10,21 @@ module.exports = {
UPDATE_PROJECT_REQUEST: 'statwrap-update-project-request',
UPDATE_PROJECT_RESPONSE: 'statwrap-update-project-response',
+ IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST: 'statwrap-import-project-template-folder-request',
+ IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE: 'statwrap-import-project-template-folder-response',
+
+ IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST: 'statwrap-import-project-template-zip-request',
+ IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE: 'statwrap-import-project-template-zip-response',
+
+ EXPORT_CUSTOM_PROJECT_TEMPLATE_REQUEST: 'statwrap-export-custom-project-template-request',
+ EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE: 'statwrap-export-custom-project-template-response',
+
+ SAVE_CUSTOM_PROJECT_TEMPLATE_REQUEST: 'statwrap-save-custom-project-template-request',
+ SAVE_CUSTOM_PROJECT_TEMPLATE_RESPONSE: 'statwrap-save-custom-project-template-response',
+
+ DELETE_CUSTOM_PROJECT_TEMPLATE_REQUEST: 'statwrap-delete-custom-project-template-request',
+ DELETE_CUSTOM_PROJECT_TEMPLATE_RESPONSE: 'statwrap-delete-custom-project-template-response',
+
// This message pair is used from the primary renderer -> main
SCAN_PROJECT_REQUEST: 'statwrap-scan-project-request',
SCAN_PROJECT_RESPONSE: 'statwrap-scan-project-response',
diff --git a/app/constants/project-templates.json b/app/constants/project-templates.json
index 8e6bb238..6139bfb8 100644
--- a/app/constants/project-templates.json
+++ b/app/constants/project-templates.json
@@ -1,6 +1,12 @@
{
"ignore": [".DS_Store", "Thumbs.db"],
"templates": [
+ {
+ "id": "STATWRAP-CUSTOM",
+ "version": "1",
+ "name": "Custom Template",
+ "description": "Design your own project structure from scratch"
+ },
{
"id": "STATWRAP-EMPTY",
"version": "1",
diff --git a/app/containers/CreateProjectDialog/CreateProjectDialog.js b/app/containers/CreateProjectDialog/CreateProjectDialog.js
index 09f1bbc7..101e643a 100644
--- a/app/containers/CreateProjectDialog/CreateProjectDialog.js
+++ b/app/containers/CreateProjectDialog/CreateProjectDialog.js
@@ -11,10 +11,10 @@ import SelectProjectTemplate from '../../components/SelectProjectTemplate/Select
import ExistingDirectory from '../../components/ExistingDirectory/ExistingDirectory';
import NewDirectory from '../../components/NewDirectory/NewDirectory';
import CloneDirectory from '../../components/CloneDirectory/CloneDirectory';
+import CustomTemplateBuilder from '../../components/CustomTemplateBuilder/CustomTemplateBuilder';
import Error from '../../components/Error/Error';
import UserContext from '../../contexts/User';
import ChecklistUtil from '../../utils/checklist';
-
import styles from './CreateProjectDialog.css';
import Messages from '../../constants/messages';
@@ -51,6 +51,8 @@ class CreateProjectDialog extends Component {
},
canProgress: false,
errorMessage: null,
+ customTemplate: null,
+ templateToDelete: null,
}
this.handleSelectAddProject = this.handleSelectAddProject.bind(this);
@@ -59,6 +61,9 @@ class CreateProjectDialog extends Component {
this.handleBack = this.handleBack.bind(this);
this.handleNext = this.handleNext.bind(this);
this.handleCreateProject = this.handleCreateProject.bind(this);
+ this.handleDeleteTemplate = this.handleDeleteTemplate.bind(this);
+ this.handleExportTemplate = this.handleExportTemplate.bind(this);
+ this.handleEditTemplate = this.handleEditTemplate.bind(this);
this.handleSelectProjectTemplate = this.handleSelectProjectTemplate.bind(this);
this.handleProjectCreated = this.handleProjectCreated.bind(this);
this.handleSourceDirectoryChanged = this.handleSourceDirectoryChanged.bind(this);
@@ -89,6 +94,11 @@ class CreateProjectDialog extends Component {
next: 'NewProjectDetails',
prev: 'SelectProjectType',
},
+ {
+ step: 'CustomTemplateBuilder',
+ next: 'NewProjectDetails',
+ prev: 'SelectNewProjectTemplate',
+ },
{
step: 'NewProjectDetails',
next: 'Create',
@@ -221,8 +231,25 @@ class CreateProjectDialog extends Component {
};
});
}
+
handleBack() {
const currentStep = this.state.step;
+
+ // If we're viewing CustomTemplateBuilder, go back to the template list
+ // instead of going all the way back to SelectProjectType
+ if (
+ currentStep === 'SelectNewProjectTemplate' &&
+ this.state.selectedTemplate &&
+ this.state.selectedTemplate.id === 'STATWRAP-CUSTOM'
+ ) {
+ this.setState({
+ selectedTemplate: null,
+ customTemplate: null,
+ canProgress: false,
+ });
+ return;
+ }
+
const stepDetails = CreateProjectDialog.steps.find((x) => x.step === currentStep);
this.setState({ step: stepDetails.prev, canProgress: false });
}
@@ -230,6 +257,30 @@ class CreateProjectDialog extends Component {
handleNext() {
const currentStep = this.state.step;
const stepDetails = CreateProjectDialog.steps.find((x) => x.step === currentStep);
+
+ // When on template selection and "Custom Template" is selected,
+ // route to CustomTemplateBuilder instead of NewProjectDetails
+ if (
+ currentStep === 'SelectNewProjectTemplate' &&
+ this.state.selectedTemplate &&
+ this.state.selectedTemplate.id === 'STATWRAP-CUSTOM' &&
+ this.state.customTemplate
+ ) {
+ ipcRenderer.send(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_REQUEST, this.state.customTemplate);
+
+ // Reload configuration once saved so the new custom template shows up in the list
+ ipcRenderer.once(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, () => {
+ ipcRenderer.send(Messages.LOAD_CONFIGURATION_REQUEST);
+ });
+ // Reset selection state to close the builder and show the main list
+ this.setState({
+ selectedTemplate: null,
+ customTemplate: null,
+ canProgress: false,
+ });
+ return;
+ }
+
this.setState({ step: stepDetails.next, canProgress: false });
}
@@ -271,7 +322,7 @@ class CreateProjectDialog extends Component {
}
handleCreateProject() {
- const { project, selectedTemplate } = this.state;
+ const { project, selectedTemplate, customTemplate } = this.state;
// For clone projects, we need to set the directory to the final path
if (project.type === Constants.ProjectType.CLONE_PROJECT_TYPE) {
@@ -287,6 +338,7 @@ class CreateProjectDialog extends Component {
const normalProject = {
...project,
template: selectedTemplate,
+ customTemplate,
};
ipcRenderer.send(Messages.CREATE_PROJECT_REQUEST, normalProject);
}
@@ -313,6 +365,38 @@ class CreateProjectDialog extends Component {
}));
}
+ handleDeleteTemplate(template) {
+ // This now just opens the confirmation dialog
+ this.setState({ templateToDelete: template });
+ }
+
+ cancelDeleteTemplate = () => {
+ // Closes the dialog without deleting
+ this.setState({ templateToDelete: null });
+ };
+
+ confirmDeleteTemplate = () => {
+ const { templateToDelete } = this.state;
+ if (templateToDelete) {
+ ipcRenderer.send(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_REQUEST, templateToDelete.id);
+ ipcRenderer.once(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, () => {
+ ipcRenderer.send(Messages.LOAD_CONFIGURATION_REQUEST);
+ });
+ }
+ // Close the dialog after deleting
+ this.setState({ templateToDelete: null });
+ };
+
+
+ handleEditTemplate(template) {
+ // Open CustomTemplateBuilder with this template pre-loaded
+ this.setState({
+ selectedTemplate: { id: 'STATWRAP-CUSTOM', version: '1' },
+ customTemplate: template,
+ canProgress: false,
+ });
+ }
+
handleNameChanged(name) {
this.setState((prevState) => {
// Determine which validation method to use based on project type
@@ -344,6 +428,19 @@ class CreateProjectDialog extends Component {
};
});
}
+
+ handleExportTemplate = (templateId) => {
+ ipcRenderer.send(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_REQUEST , templateId);
+
+ ipcRenderer.once(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE, (event, response) => {
+ if(response.canceled) return;
+
+ if(response.error){
+ this.setState({ errorMessage : response.errorMessage});
+ };
+ });
+ };
+
render() {
const { open, onClose, projectTemplates } = this.props;
const currentStep = this.state.step;
@@ -354,16 +451,27 @@ class CreateProjectDialog extends Component {
let progressButton = null;
if (hasNextStep) {
+ // Check if we are currently showing the Custom Template Builder
+ const isCustomBuilder =
+ currentStep === 'SelectNewProjectTemplate' &&
+ this.state.selectedTemplate &&
+ this.state.selectedTemplate.id === 'STATWRAP-CUSTOM';
+
progressButton =
stepDetails.next === 'Create' ? (
-
+
+ ) : isCustomBuilder ? (
+ // Show "Save" button without the forward arrow icon
+
) : (
);
}
+
let backButton = (
+
);
}
diff --git a/app/containers/ProjectPage/ProjectPage.js b/app/containers/ProjectPage/ProjectPage.js
index 5b32ed5b..f823ca4a 100644
--- a/app/containers/ProjectPage/ProjectPage.js
+++ b/app/containers/ProjectPage/ProjectPage.js
@@ -1,6 +1,5 @@
import React, { Component } from 'react';
import { ipcRenderer } from 'electron';
-// import ResizablePanels from 'resizable-panels-react';
import Projects from '../../components/Projects/Projects';
import Project from '../../components/Project/Project';
import CreateProjectDialog from '../CreateProjectDialog/CreateProjectDialog';
diff --git a/app/main.dev.js b/app/main.dev.js
index cf47ff6a..93bb87bb 100644
--- a/app/main.dev.js
+++ b/app/main.dev.js
@@ -9,12 +9,12 @@
* `./app/main.prod.js` using webpack. This gives us some performance wins.
*
*/
-import { app, shell, BrowserWindow, ipcMain, screen } from 'electron';
+import { app, shell, BrowserWindow, ipcMain, screen, dialog } from 'electron';
// import { autoUpdater } from 'electron-updater';
import path from 'path';
import fs from 'fs';
import { URL } from 'url';
-import { orderBy } from 'lodash';
+import { orderBy, template } from 'lodash';
import { initialize, enable as enableRemote } from '@electron/remote/main';
import MenuBuilder from './menu';
import LogWatcherService from './services/logWatcher';
@@ -53,7 +53,6 @@ const projectListService = new ProjectListService();
const sourceControlService = new SourceControlService();
const logService = new LogService();
const checklistService = new ChecklistService();
-
// The LogWatcherService requires a window from which we can send messages, so we can't
// construct it until the BrowserWindow is created.
let logWatcherService = null;
@@ -203,6 +202,10 @@ const createWindow = async () => {
//new AppUpdater();
};
+// Directory where user-created custom templates are persisted
+const getCustomTemplatesDir = () =>
+ path.join(app.getPath('userData'), Constants.StatWrapFiles.CUSTOM_PROJECT_TEMPLATES);
+
/**
* Add event listeners...
*/
@@ -329,6 +332,14 @@ ipcMain.on(Messages.LOAD_CONFIGURATION_REQUEST, async (event) => {
response.projectTemplates = projectTemplateService.loadProjectTemplates(
path.join(__dirname, './templates'),
);
+
+ // Load and merge any user-created custom templates
+ const customTemplates = projectTemplateService.loadCustomTemplates(
+ getCustomTemplatesDir(),
+ );
+ if(customTemplates.length > 0){
+ response.projectTemplates = projectTemplateService.mergeCustomTemplates(customTemplates);
+ }
} catch (e) {
response.error = true;
response.errorMessage = 'There was an unexpected error when loading the list of project types';
@@ -424,6 +435,55 @@ ipcMain.on(Messages.RENAME_PROJECT_LIST_ENTRY_REQUEST, async (event, projectId,
event.sender.send(Messages.RENAME_PROJECT_LIST_ENTRY_RESPONSE, response);
});
+/**
+ * Opens the native OS folder picker, scans the selected folder for dangerous
+ * file extensions (.exe, .dll, .sh), and returns a template object built from
+ * the folder's structure.
+*/
+ipcMain.on(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST, async (event)=>{
+ const response = {
+ canceled: false,
+ template: null,
+ blockedExtensions: [],
+ error: false,
+ errorMessage: '',
+ };
+
+ try{
+ const parentWindow = BrowserWindow.fromWebContents(event.sender)|| mainWindow;
+ const result = await dialog.showOpenDialog(parentWindow, {
+ title: 'Select a folder to import',
+ properties: ['openDirectory'],
+ });
+
+ if(result.canceled || !result.filePaths || result.filePaths.length === 0){
+ response.canceled =true;
+ event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE, response);
+ return;
+ }
+
+ const folderPath = result.filePaths[0];
+ const templateResult = projectTemplateService.buildTemplateFromFolder(folderPath);
+
+ response.template = templateResult.template;
+ response.blockedExtensions= templateResult.blockedExtensions;
+
+ if(response.blockedExtensions.length > 0){
+ response.error = true;
+ response.errorMessage = `This folder contains ${response.blockedExtensions.join(
+ ', ',
+ )} files. StatWrap does not accept this.`;
+ response.template = null;
+ }
+ }catch(e){
+ response.error = true;
+ response.errorMessage = 'There was an unexpected error while importing the template';
+ console.log(e);
+ }
+
+ event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE, response);
+});
+
/**
* Create a new project - instantiating the project metadata within the project root,
* and also registering the project within the user's project list.
@@ -454,12 +514,31 @@ ipcMain.on(Messages.CREATE_PROJECT_REQUEST, async (event, project) => {
response.errorMessage = `No project template was specified or selected`;
} else {
projectService.initializeNewProject(validationReport.project, project.template);
- projectTemplateService.createTemplateContents(
+
+ // Custom imported templates use createTemplateContentsFromContents
+ // because they aren't in the projectTemplates cache.
+ // Built-in templates use the existing createTemplateContents which
+ // looks up the template by ID and version from the cache.
+
+ if(project.template.id === 'STATWRAP-CUSTOM'){
+ if (!project.customTemplate || !project.customTemplate.contents) {
+ response.error = true;
+ response.errorMessage = 'Custom template was not imported or is empty';
+ } else {
+ projectTemplateService.createTemplateContentsFromContents(
+ validationReport.project.path,
+ project.customTemplate.contents,
+ );
+ }
+ }else{
+ projectTemplateService.createTemplateContents(
validationReport.project.path,
project.template.id,
project.template.version,
);
+ }
+ if (!response.error) {
// We are going to do just a FileHandler scan of the assets. Even when we have more handlers, we don't
// need to store or cache those results in the project file (at least initially). If that changes, we
// should see if we can have a single initialization of the AssetService instead of doing it here and
@@ -468,7 +547,8 @@ ipcMain.on(Messages.CREATE_PROJECT_REQUEST, async (event, project) => {
validationReport.project.assets = assetService.scan(validationReport.project.path);
projectService.saveProjectFile(validationReport.project.path, validationReport.project);
}
- break;
+ }
+ break;
}
case Constants.ProjectType.EXISTING_PROJECT_TYPE: {
// Let's see if a StatWrap project configuration file already exists at that location.
@@ -931,6 +1011,45 @@ ipcMain.on(Messages.CREATE_UPDATE_PERSON_REQUEST, async (event, mode, project, p
event.sender.send(Messages.CREATE_UPDATE_PERSON_RESPONSE, response);
});
+
+/**
+ * Save a custom template to disk so it persists across app restarts.
+ * After saving, the template is merged into the in-memory template list.
+ */
+ipcMain.on(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_REQUEST, async (event,template) => {
+ const response ={
+ template: null,
+ error: false,
+ errorMessage: '',
+ };
+
+ try{
+ // Generate a unique ID if the template does not have one
+ if(!template.id || template.id === 'STATWRAP-CUSTOM'){
+ template.id = uuidv4();
+ }
+ template.version = template.version || '1';
+
+ const saved = projectTemplateService.saveCustomTemplate(
+ getCustomTemplatesDir(),
+ template,
+ );
+ response.template = saved;
+
+ // Refresh the in-memory template list
+ const customTemplates = projectTemplateService.loadCustomTemplates(
+ getCustomTemplatesDir(),
+ );
+ projectTemplateService.mergeCustomTemplates(customTemplates);
+ }catch(e){
+ response.error = true;
+ response.errorMessage = 'Failed to save the custom template';
+ console.log(e);
+ }
+
+ event.sender.send(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response);
+});
+
/**
* Received when the current user's profile information needs to be saved
*/
@@ -1150,6 +1269,31 @@ ipcMain.on(Messages.SEARCH_INDEX_REINDEX_REQUEST, async (event, searchSettings)
event.sender.send(Messages.SEARCH_INDEX_REINDEX_RESPONSE, response);
});
+/**
+ * Delete a custom template from disk
+ */
+ipcMain.on(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_REQUEST, async (event, templateId) => {
+ const response ={
+ templateId,
+ error: false,
+ errorMessage: '',
+ };
+
+ try{
+ projectTemplateService.deleteCustomTemplate(getCustomTemplatesDir(),templateId);
+ //Refresh the in-memory template list
+ const customTemplates = projectTemplateService.loadCustomTemplates(
+ getCustomTemplatesDir(),
+ );
+ projectTemplateService.mergeCustomTemplates(customTemplates);
+ }catch(e){
+ response.error = true;
+ response.errorMessage = 'Failed to delete the custom template';
+ console.log(e);
+ }
+
+ event.sender.send(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response);
+});
/**
* Handle a request to delete the search index
@@ -1249,3 +1393,142 @@ ipcMain.on(Messages.SEARCH_GET_SUGGESTIONS_REQUEST, async (event, query) => {
event.sender.send(Messages.SEARCH_GET_SUGGESTIONS_RESPONSE, response);
});
+
+ipcMain.on(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST, async (event) => {
+ const response = {
+ canceled: false,
+ template: null,
+ error: false,
+ errorMessage: '',
+ };
+
+ try {
+ const parentWindow =
+ BrowserWindow.fromWebContents(event.sender) || mainWindow;
+ const result = await dialog.showOpenDialog(parentWindow, {
+ title: 'Select a template zip file to import',
+ filters: [{ name: 'Zip Archives', extensions: ['zip'] }],
+ properties: ['openFile'],
+ });
+
+ if (
+ result.canceled ||
+ !result.filePaths ||
+ result.filePaths.length === 0
+ ) {
+ response.canceled = true;
+ event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, response);
+ return;
+ }
+
+ const zipFilePath = result.filePaths[0];
+
+ // Checking for ZIP file size
+ const MAX_ZIP_SIZE = 5 * 1024 * 1024; // 5 MB Max ZIP Size
+ const zipstats = fs.statSync(zipFilePath);
+ if(zipstats.size> MAX_ZIP_SIZE){
+ response.error = true;
+ response.errorMessage = `The zip file is ${(zipstats.size / (1024 * 1024)).toFixed(1)} MB, which exceeds the 5 MB limit.`;
+ event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE,response);
+ return;
+ }
+
+ const AdmZip = require('adm-zip');
+
+ // Create a temporary directory for extraction
+ const tempDir = path.join(
+ app.getPath('temp'),
+ `statwrap-template-${Date.now()}`
+ );
+ fs.mkdirSync(tempDir, { recursive: true });
+
+ // Extract the zip
+ try {
+ const zip = new AdmZip(zipFilePath);
+ zip.extractAllTo(tempDir, true);
+ } catch (zipErr) {
+ response.error = true;
+ response.errorMessage =
+ 'Failed to extract zip file. Please make sure it is a valid zip archive.';
+ event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, response);
+ return;
+ }
+
+ let scanPath = tempDir;
+ const subdirs = fs
+ .readdirSync(tempDir)
+ .filter((f) => f !== '.DS_Store' && f !== '__MACOSX' && f !=='.git');
+ if (
+ subdirs.length === 1 &&
+ fs.statSync(path.join(tempDir, subdirs[0])).isDirectory()
+ ) {
+ scanPath = path.join(tempDir, subdirs[0]);
+ }
+
+ // Scanning the extracted folder for template contents
+ const templateResult =
+ projectTemplateService.buildTemplateFromFolder(scanPath);
+ response.template = templateResult.template;
+
+ response.template.name = path.basename(zipFilePath, '.zip');
+
+ // Security Checkings
+ if (templateResult.blockedExtensions.length > 0) {
+ response.error = true;
+ response.errorMessage = `This folder contains ${templateResult.blockedExtensions.join(
+ ', '
+ )} files. StatWrap does not accept this.`;
+ response.template = null;
+ }
+ } catch (e) {
+ response.error = true;
+ response.errorMessage =
+ e.message ||
+ 'There was an unexpected error while importing the template';
+ console.log(e);
+ }
+
+ event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, response);
+});
+
+/**
+ * Export the Custom Template as ZIP folder(.zip)
+ */
+ipcMain.on(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_REQUEST, async (event, templateId) => {
+ const response = {
+ canceled: false,
+ error: false,
+ errorMessage: '',
+ };
+ try {
+ const parentWindow = BrowserWindow.fromWebContents(event.sender) || mainWindow;
+ // Open a Save dialog that asks where to save the .zip file
+ const result = await dialog.showSaveDialog(parentWindow, {
+ title: 'Export Template',
+ defaultPath: `${templateId}.zip`,
+ filters: [
+ { name: 'ZIP Archive', extensions: ['zip'] },
+ ],
+ });
+ if (result.canceled || !result.filePath) {
+ response.canceled = true;
+ event.sender.send(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response);
+ return;
+ }
+ // Ensure the path always ends in .zip even if the user accidentally removed it
+ let exportPath = result.filePath;
+ if (!exportPath.endsWith('.zip')) {
+ exportPath = `${exportPath}.zip`;
+ }
+ projectTemplateService.exportCustomTemplate(
+ getCustomTemplatesDir(),
+ templateId,
+ exportPath,
+ );
+ } catch (e) {
+ response.error = true;
+ response.errorMessage = `Failed to export the template: ${e.message}`;
+ console.log(e);
+ }
+ event.sender.send(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response);
+});
\ No newline at end of file
diff --git a/app/package.json b/app/package.json
index 2eb0007e..29322bfb 100644
--- a/app/package.json
+++ b/app/package.json
@@ -15,6 +15,7 @@
},
"license": "MIT",
"dependencies": {
+ "adm-zip": "^0.5.17",
"chokidar": "^3.5.3",
"flexsearch": "^0.7.43"
},
diff --git a/app/services/projectTemplate.js b/app/services/projectTemplate.js
index 25a634f2..f5aea491 100644
--- a/app/services/projectTemplate.js
+++ b/app/services/projectTemplate.js
@@ -1,7 +1,57 @@
+
import templateList from '../constants/project-templates.json';
+import Constants from '../constants/constants';
+import { FILE } from 'dns';
+import { v4 as uuidv4 } from 'uuid';
const fs = require('fs');
const path = require('path');
+const AdmZip = require('adm-zip');
+
+const BLOCKED_TEMPLATE_EXTENSIONS = [
+ // Microsoft executables
+ '.exe', '.com',
+ // Microsoft binary libraries
+ '.dll',
+ // Microsoft executable scripts
+ '.bat', '.pif', '.scr',
+ // Shell scripts
+ '.sh',
+ // Visual Basic files
+ '.vb', '.vbe', '.vbs',
+ // Other vulnerable Microsoft files
+ '.chm', '.hlp', '.inf', '.isp', '.lnk', '.msc', '.msi', '.msp', '.reg', '.shb', '.shs',
+ '.wsc', '.wsf', '.wsh',
+ // Microsoft/Installshield Cabinet files
+ '.cab',
+ // Java binaries
+ '.jar',
+ // OS X DMG files
+ '.dmg',
+ // OS X install scripts
+ '.mpkg',
+ // Debian/RedHat packages
+ '.deb', '.rpm',
+ // Tape archives
+ '.tar', '.cpio',
+ // Compressed files
+ '.f', '.gz', '.bz', '.bz2', '.lzo', '.z', '.emz',
+ // Other compressed archives
+ '.7z', '.rar', '.lha', '.arj', '.arc', '.zoo', '.sit',
+];
+const SKIPPED_TEMPLATE_EXTENSIONS = [
+ // Images
+ '.jpg', '.jpeg', '.gif', '.png', '.tif', '.tiff', '.pcx', '.bmp',
+ // Data files
+ '.csv',
+ // Vector graphics
+ '.svg', '.eps',
+ // Windows Metafiles
+ '.wmf',
+ // Cursors and icons
+ '.ani', '.cur', '.ico',
+];
+const MAX_TOTAL_FOLDER_SIZE = 5*1024*1024; //5 MB MAX FOLDER SIZE
// Recursive function to get the hierarchy of files and folders in dirPath
// Derived from https://coderrocketfuel.com/article/recursively-list-all-the-files-in-a-directory-using-node-js
@@ -14,14 +64,14 @@ function getAllFiles(dirPath) {
if (fs.statSync(filePath).isDirectory()) {
arrayOfFiles.push({
name: file,
- type: 'folder',
+ type: Constants.AssetType.DIRECTORY,
path: path.join(dirPath, '/', file),
contents: getAllFiles(filePath),
});
} else {
arrayOfFiles.push({
name: file,
- type: 'file',
+ type: Constants.AssetType.FILE,
path: path.join(dirPath, '/', file),
});
}
@@ -31,16 +81,131 @@ function getAllFiles(dirPath) {
return arrayOfFiles;
}
+/**
+ * Extract all extensions from a filename
+ */
+function getAllExtensions(filename) {
+ const parts = filename.split('.');
+ if (parts.length <= 1) return [];
+ return parts.slice(1).map((ext) => `.${ext.toLowerCase()}`);
+}
+
+//Recursive function to get the folders contents in the dirPath
+function collectImportedFolderContents(
+ dirPath,
+ blockedExtensions,
+ foundExtensions,
+ statsTracker = { totalSize: 0 }
+){
+ const files = fs.readdirSync(dirPath);
+ const arrayOfFiles = [];
+
+ files.forEach(function (file){
+ if(templateList.ignore.includes(file)){
+ return;
+ }
+
+ const filePath = path.join(dirPath,file);
+ const lstats = fs.lstatSync(filePath);
+
+ if(lstats.isSymbolicLink()){
+ console.log(`Ignoring symbolic link: ${filePath}`);
+ return;
+ }
+
+ if(lstats.isDirectory()){
+ arrayOfFiles.push({
+ name: file,
+ type: Constants.AssetType.DIRECTORY,
+ path: path.join(dirPath,'/',file),
+ contents: collectImportedFolderContents(filePath,blockedExtensions,foundExtensions,statsTracker),
+ });
+ return;
+ }
+
+ statsTracker.totalSize += lstats.size;
+ if(statsTracker.totalSize > MAX_TOTAL_FOLDER_SIZE){
+ throw new Error(`Import Aborted : Total template size exceeds the limit of 5MB`);
+ }
+
+ const extensions = getAllExtensions(file);
+ const blockedExt = extensions.find((ext) => blockedExtensions.includes(ext));
+
+ // Check if ANY extension in the filename is blocked
+ if(blockedExt){
+ foundExtensions.add(blockedExt);
+ return;
+ }
+
+ // Check if ANY extension in the filename should be skipped
+ const skippedExt = extensions.find((ext) => SKIPPED_TEMPLATE_EXTENSIONS.includes(ext));
+ if(skippedExt){
+ return;
+ }
+
+ arrayOfFiles.push({
+ name: file,
+ type: Constants.AssetType.FILE,
+ path: path.join(dirPath, '/', file),
+ });
+ });
+
+ return arrayOfFiles;
+
+}
+
+// Recursively copy files to app data folder and update the JSON structure paths
+function copyContentsAndUpdatePaths(contents, targetBaseDir) {
+ if (!fs.existsSync(targetBaseDir)) {
+ fs.mkdirSync(targetBaseDir, { recursive: true });
+ }
+
+ return contents.map((item) => {
+ const newPath = path.join(targetBaseDir, item.name);
+ if (item.type === Constants.AssetType.FILE) {
+ fs.copyFileSync(item.path, newPath);
+ return { ...item, path: newPath };
+ } else {
+ if (!fs.existsSync(newPath)) {
+ fs.mkdirSync(newPath, { recursive: true });
+ }
+ const newContents = copyContentsAndUpdatePaths(item.contents, newPath);
+ return { ...item, path: newPath, contents: newContents };
+ }
+ });
+}
+
+//Function to confirm target path is strictly inside target directory i.e to prevent the path traversal
+function isSafeTargetPath(baseDir, targetPath) {
+
+ const resolvedBase = fs.existsSync(baseDir)
+ ? fs.realpathSync(baseDir)
+ : path.resolve(baseDir);
+
+ // Normalize target path
+ const resolvedTarget = path.resolve(targetPath);
+
+ const relative = path.relative(resolvedBase, resolvedTarget);
+
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
+}
+
// For a given template, create all of the files and folders in dirPath
// This handles recursively defined template structures.
-function createAllTemplateItems(dirPath, contents) {
+function createAllTemplateItems(dirPath, contents, rootdirPath = dirPath) {
contents.forEach(function (item) {
- const newPath = path.join(dirPath, item.name);
- if (item.type === 'file') {
+ const rootname = path.basename(item.name);
+ const newPath = path.join(dirPath, rootname);
+
+ if(!isSafeTargetPath(rootdirPath, newPath)){
+ throw new Error(`Security Exception: Blocked path traversal attempt to write outside project directory.`);
+ }
+
+ if (item.type === Constants.AssetType.FILE) {
fs.copyFileSync(item.path, newPath);
} else {
fs.mkdirSync(newPath);
- createAllTemplateItems(newPath, item.contents);
+ createAllTemplateItems(newPath, item.contents, rootdirPath);
}
});
}
@@ -87,10 +252,57 @@ export default class ProjectTemplateService {
this.projectTemplates = [...templates];
- // TODO: Can merge in user-defined project templates later. Right now just our pre-defined ones
return this.projectTemplates;
};
+ // Building the template from the folder
+ buildTemplateFromFolder = (folderPath) =>{
+ if(!folderPath){
+ throw new Error('You must specify a directory to import');
+ }
+
+ try{
+ fs.accessSync(folderPath);
+ }catch(err){
+ throw new Error(`The directory ${folderPath} does not exist`);
+ }
+
+ const foundExtensions = new Set();
+ const contents = collectImportedFolderContents(
+ folderPath,
+ BLOCKED_TEMPLATE_EXTENSIONS,
+ foundExtensions,
+ );
+
+ return{
+ template: {
+ id: uuidv4(),
+ version: '1',
+ name: path.basename(folderPath, '.zip'),
+ contents,
+ },
+ blockedExtensions: Array.from(foundExtensions).sort(),
+ };
+ };
+
+ createTemplateContentsFromContents = (baseDirectory, contents) => {
+ if(!baseDirectory){
+ throw new Error('You must specify a base directory to create the template in');
+ }
+
+ try{
+ fs.accessSync(baseDirectory);
+ }catch(err){
+ throw new Error(`The base directory ${baseDirectory} does not exist`);
+ }
+
+ if(!contents || !Array.isArray(contents)){
+ throw new Error('You must provide template contents');
+ }
+
+ createAllTemplateItems(baseDirectory,contents);
+ }
+
// Instantiate baseDirectory (assumed to be the root of the project we want the
// template created in) with all files and folders stored in the template identified
// by templateId
@@ -120,4 +332,99 @@ export default class ProjectTemplateService {
createAllTemplateItems(baseDirectory, template.contents);
};
+
+ /**
+ * Save a custom template definition to disk.
+ * Each template is saved as a JSON file named by its ID.
+ */
+ saveCustomTemplate = (customTemplatesDir, template) => {
+ if (!fs.existsSync(customTemplatesDir)) {
+ fs.mkdirSync(customTemplatesDir, { recursive: true });
+ }
+
+ // Copy template files to a permanent directory inside appData
+ const permanentFilesDir = path.join(customTemplatesDir, 'files', template.id);
+ const updatedContents = copyContentsAndUpdatePaths(template.contents, permanentFilesDir);
+
+ const templateToSave = {
+ ...template,
+ contents: updatedContents,
+ isCustom: true,
+ };
+
+ const filePath = path.join(customTemplatesDir, `${template.id}.json`);
+ fs.writeFileSync(filePath, JSON.stringify(templateToSave, null, 2), 'utf-8');
+ return templateToSave;
+ };
+
+ /***
+ * Load all the custom templates from the custom-templates directory.
+ */
+
+ loadCustomTemplates = (customTemplatesDir) => {
+ if(!fs.existsSync(customTemplatesDir)){
+ return [];
+ }
+
+ const files = fs.readdirSync(customTemplatesDir).filter((f)=> f.endsWith('.json'));
+ const templates = [];
+
+ files.forEach((file) => {
+ try{
+ const content = fs.readFileSync(path.join(customTemplatesDir, file), 'utf-8');
+ const template = JSON.parse(content);
+ template.isCustom = true;
+ templates.push(template);
+ }catch(e){
+ console.log(`Failed to load custom template ${file}:`, e);
+ }
+ });
+
+ return templates;
+ };
+
+ /**
+ * Merge the custom templates into the cached lsit so they appear
+ * alongside the hardcoded ones.
+ */
+
+ mergeCustomTemplates = (customTemplates) => {
+ if(!this.projectTemplates){
+ this.projectTemplates =[];
+ }
+
+ this.projectTemplates = this.projectTemplates.filter((f) => !f.isCustom);
+ this.projectTemplates.push(...customTemplates);
+ return this.projectTemplates;
+
+ }
+
+
+ /**
+ * Delete a custom template from disk.
+ */
+
+ deleteCustomTemplate = (customTemplatesDir, templateId) => {
+ const filePath = path.join(customTemplatesDir, `${templateId}.json`);
+ if(fs.existsSync(filePath)){
+ fs.unlinkSync(filePath);
+ return true;
+ }
+ return false;
+ };
+
+ /**
+ * Export a custom template
+ */
+ exportCustomTemplate = (customTemplatesDir, templateId, exportPath) => {
+ const zip = new AdmZip();
+ // 1. Add the template's associated files directly into the ZIP root
+ const filesDir = path.join(customTemplatesDir, 'files', templateId);
+ if (fs.existsSync(filesDir)) {
+ zip.addLocalFolder(filesDir, "");
+ }
+ // 2. Write the ZIP buffer to the exportPath
+ zip.writeZip(exportPath);
+ };
+
}
diff --git a/app/templates/STATWRAP-CUSTOM/1/.keep b/app/templates/STATWRAP-CUSTOM/1/.keep
new file mode 100644
index 00000000..e69de29b
diff --git a/app/utils/checklist.js b/app/utils/checklist.js
index 0edefbcd..47ea31a7 100644
--- a/app/utils/checklist.js
+++ b/app/utils/checklist.js
@@ -3,6 +3,7 @@ import Constants from '../constants/constants';
import AssetsConfig from '../constants/assets-config';
import AssetUtil from './asset';
import WorkflowUtil from './workflow';
+import { v4 as uuidv4 } from 'uuid';
const path = require('path');
export default class ChecklistUtil {
@@ -15,6 +16,8 @@ export default class ChecklistUtil {
Constants.CHECKLIST.forEach((statement, index) => {
checklist.push({
id: index + 1,
+ uid: uuidv4(),
+ order: index + 1,
name: statement[0],
statement: statement[1],
answer: false,
@@ -22,11 +25,199 @@ export default class ChecklistUtil {
notes: [],
assets: [],
subChecklist: [],
+ source: 'default',
});
});
return checklist;
}
+
+ /**
+ * Sanitizes a checklist name by trimming whitespace and enforcing the max length.
+ * @param {string} name The raw name string to sanitize
+ * @returns {string} The sanitized name, or empty string if input is invalid
+ */
+ static sanitizeChecklistName(name) {
+ if (typeof name !== 'string') {
+ return '';
+ }
+ return name.trim().substring(0, Constants.CHECKLIST_NAME_MAX_LENGTH);
+ }
+
+
+ /**
+ * Sanitizes a checklist description by trimming whitespace and enforcing the max length.
+ * @param {string} description The raw description string to sanitize
+ * @returns {string} The sanitized description, or empty string if input is invalid
+ */
+ static sanitizeChecklistDescription(description) {
+ if (typeof description !== 'string') {
+ return '';
+ }
+ return description.trim().substring(0, Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH);
+ }
+
+
+ /**
+ * Generates the export JSON object containing only the checklist names and descriptions.
+ * Excludes notes, assets, scan results, contents, etc.
+ * @param {Array} checklist The full checklist array from the project
+ * @returns {Object} The export-ready JSON object
+ */
+ static generateChecklistExport(checklist) {
+ return {
+ type: Constants.CHECKLIST_EXPORT_TYPE,
+ version: Constants.CHECKLIST_EXPORT_VERSION,
+ exportedAt: new Date().toISOString(),
+ checklists: checklist.map((item) => ({
+ name: item.statement || item.name || '',
+ description: item.description || '',
+ })),
+ };
+ }
+
+
+ /**
+ * Validates and extracts checklst items from an imported JSON object.
+ * @param {string} jsonString The raw JSON string from the imported file
+ * @param {Array} existingChecklist The current checklist (for duplicate detection)
+ * @returns {Object} { valid, error, items, skippedCount }
+ */
+ static validateAndParseImport(jsonString, existingChecklist) {
+ // Parse JSON
+ let parsed;
+ try {
+ parsed = JSON.parse(jsonString);
+ } catch (e) {
+ return {
+ valid: false,
+ error: 'The selected file is not valid JSON. Please check the file and try again.',
+ items: [],
+ skippedCount: 0,
+ };
+ }
+
+ // Validate the Structure
+ if (!parsed || parsed.type !== Constants.CHECKLIST_EXPORT_TYPE) {
+ return {
+ valid: false,
+ error: 'This file does not appear to be a StatWrap checklist export. '
+ + 'It is missing the required "type" field.',
+ items: [],
+ skippedCount: 0,
+ };
+ }
+
+ // Check that the 'checklists' field exists and is an array.
+ if (!Array.isArray(parsed.checklists)) {
+ return {
+ valid: false,
+ error: 'This file does not contain a valid "checklists" array.',
+ items: [],
+ skippedCount: 0,
+ };
+ }
+
+ // Check that the array is not empty.
+ if (parsed.checklists.length === 0) {
+ return {
+ valid: false,
+ error: 'The imported file contains an empty checklist. There is nothing to import.',
+ items: [],
+ skippedCount: 0,
+ };
+ }
+
+ // Extract and sanitize each item i.e. name and description
+ const existingNames = new Set(
+ existingChecklist.map((item) => (item.statement || item.name || '').toLowerCase())
+ );
+
+ const validItems = [];
+ let skippedCount = 0;
+
+ parsed.checklists.forEach((rawItem) => {
+ // Each item must have name
+ if (!rawItem || typeof rawItem.name !== 'string' || rawItem.name.trim() === '') {
+ skippedCount++;
+ return;
+ }
+
+ const sanitizedName = ChecklistUtil.sanitizeChecklistName(rawItem.name);
+ const sanitizedDescription = ChecklistUtil.sanitizeChecklistDescription(
+ rawItem.description || ''
+ );
+
+ if (existingNames.has(sanitizedName.toLowerCase())) {
+ skippedCount++;
+ return;
+ }
+
+ existingNames.add(sanitizedName.toLowerCase());
+ validItems.push({
+ name: sanitizedName,
+ description: sanitizedDescription,
+ });
+ });
+
+ if (validItems.length === 0) {
+ return {
+ valid: false,
+ error: skippedCount > 0
+ ? `All ${skippedCount} item(s) in the file were either duplicates of existing checklists or had invalid/empty names.`
+ : 'No valid checklist items were found in the file.',
+ items: [],
+ skippedCount,
+ };
+ }
+
+ return {
+ valid: true,
+ error: null,
+ items: validItems,
+ skippedCount,
+ };
+ }
+
+
+ /**
+ * Recalculates the 'order' field for all items based on their current
+ * position in the array. Call this after any add, delete, or reorder operation.
+ */
+ static renumberChecklist(checklist) {
+ return checklist.map((item, index) => ({
+ ...item,
+ order: index + 1,
+ }));
+ }
+
+
+ /**
+ * Checks if a custom checklist statement already exists in the checklist.
+ *
+ * @param {string} name - The checklist statement to check for duplication
+ * @param {Array} checklist - The array of current checklist items
+ * @param {string|number} - The ID or UID of the item to ignore
+ * @returns {boolean} true if a duplicate is found, false otherwise
+ */
+ static isDuplicateChecklist(name, checklist, excludeId = null) {
+ if (!name || !checklist || !Array.isArray(checklist)) {
+ return false;
+ }
+
+ const sanitizedInput = ChecklistUtil.sanitizeChecklistName(name).toLowerCase();
+
+ return checklist.some((item) => {
+ if (excludeId && (item.uid === excludeId || item.id === excludeId)) {
+ return false;
+ }
+
+ const statement = item.statement ? item.statement.toLowerCase() : '';
+ return statement === sanitizedInput;
+ });
+ }
+
+
/**
* This function returns the languages and dependencies of the project
* @param {object} asset The root project asset to find the languages and dependencies of
diff --git a/app/utils/templateContent.js b/app/utils/templateContent.js
new file mode 100644
index 00000000..565731dc
--- /dev/null
+++ b/app/utils/templateContent.js
@@ -0,0 +1,43 @@
+import Constants from '../constants/constants';
+
+/**
+ * Keep only the items whose path is in checkedPaths.
+ * Directory are kept if they are checked or have checked descendants.
+ */
+
+function filterContentsByPaths(contents, checkedPaths) {
+ if (!contents) return [];
+ const filtered = [];
+ contents.forEach((item) => {
+ if (item.type === Constants.AssetType.DIRECTORY) {
+ if (checkedPaths.includes(item.path)) {
+ const filteredChildren = filterContentsByPaths(item.contents, checkedPaths);
+ filtered.push({ ...item, contents: filteredChildren });
+ }
+ } else {
+ if (checkedPaths.includes(item.path)) {
+ filtered.push(item);
+ }
+ }
+ });
+ return filtered;
+}
+
+/**
+ * Recursively collect all paths from template contents
+ */
+
+function collectAllPaths(contents) {
+ const paths = [];
+ if (contents) {
+ contents.forEach((item) => {
+ paths.push(item.path);
+ if (item.type === Constants.AssetType.DIRECTORY && item.contents) {
+ paths.push(...collectAllPaths(item.contents));
+ }
+ });
+ }
+ return paths;
+}
+
+export { filterContentsByPaths, collectAllPaths };
\ No newline at end of file
diff --git a/app/yarn.lock b/app/yarn.lock
index c19276a2..3c80e7f0 100644
--- a/app/yarn.lock
+++ b/app/yarn.lock
@@ -9,6 +9,11 @@
dependencies:
flexsearch "*"
+adm-zip@^0.5.17:
+ version "0.5.17"
+ resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a"
+ integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==
+
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz"
diff --git a/test/components/CustomTemplateBuilder.spec.js b/test/components/CustomTemplateBuilder.spec.js
new file mode 100644
index 00000000..7b60118a
--- /dev/null
+++ b/test/components/CustomTemplateBuilder.spec.js
@@ -0,0 +1,652 @@
+import React from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { ipcRenderer } from 'electron';
+import CustomTemplateBuilder from '../../app/components/CustomTemplateBuilder/CustomTemplateBuilder';
+import Messages from '../../app/constants/messages';
+
+
+jest.mock('electron', () => {
+ const handlers = {};
+ return {
+ ipcRenderer: {
+ send: jest.fn(),
+ once: jest.fn((channel, callback) => {
+ handlers[channel] = callback;
+ }),
+ __handlers: handlers,
+ },
+ };
+});
+
+jest.mock('@mui/material', () => {
+ const React = require('react');
+
+ const MockButton = ({ children, onClick, ...props }) =>
+ React.createElement('mock-button', { onClick, ...props }, children);
+
+ const MockTextField = ({ value, onChange, placeholder, ...props }) =>
+ React.createElement('mock-text-field', { value, onChange, placeholder, ...props });
+
+ const MockIconButton = ({ children, onClick, ...props }) =>
+ React.createElement('mock-icon-button', { onClick, ...props }, children);
+
+ return {
+ Button: MockButton,
+ TextField: MockTextField,
+ IconButton: MockIconButton,
+ };
+});
+
+jest.mock('@mui/icons-material/DriveFolderUpload', () => {
+ const React = require('react');
+ return {
+ __esModule: true,
+ default: () => React.createElement('mock-drive-folder-upload-icon'),
+ };
+});
+
+jest.mock('@mui/icons-material/FileUpload', () => {
+ const React = require('react');
+ return {
+ __esModule: true,
+ default: () => React.createElement('mock-file-upload-icon'),
+ };
+});
+
+jest.mock('../../app/components/ProjectTemplatePreview/ProjectTemplatePreview', () => {
+ const React = require('react');
+ return {
+ __esModule: true,
+ default: (props) => React.createElement('mock-project-template-preview', props),
+ };
+});
+
+jest.mock('../../app/components/Error/Error', () => {
+ const React = require('react');
+ return {
+ __esModule: true,
+ default: ({ children }) => React.createElement('mock-error', null, children),
+ };
+});
+
+const createTemplateFixture = () => ({
+ id: 'STATWRAP-CUSTOM',
+ version: '1',
+ name: 'Imported Template',
+ description: 'Imported description',
+ contents: [
+ {
+ type: 'directory',
+ name: 'Chapter 1',
+ path: '/chapter-1',
+ contents: [
+ {
+ type: 'file',
+ name: 'outline.md',
+ path: '/chapter-1/outline.md',
+ },
+ {
+ type: 'directory',
+ name: 'data',
+ path: '/chapter-1/data',
+ contents: [
+ {
+ type: 'file',
+ name: 'sample.csv',
+ path: '/chapter-1/data/sample.csv',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'file',
+ name: 'README.md',
+ path: '/README.md',
+ },
+ ],
+});
+
+const createRenderer = (props = {}) => {
+ const onValidationChange = jest.fn();
+ const onTemplateReady = jest.fn();
+ let renderer;
+
+ act(() => {
+ renderer = TestRenderer.create(
+
,
+ );
+ });
+
+ return {
+ renderer,
+ instance: renderer.getInstance(),
+ onValidationChange,
+ onTemplateReady,
+ };
+};
+
+const getAllPaths = () => [
+ '/chapter-1',
+ '/chapter-1/outline.md',
+ '/chapter-1/data',
+ '/chapter-1/data/sample.csv',
+ '/README.md',
+];
+
+const getNestedSelection = () => [
+ '/chapter-1',
+ '/chapter-1/data',
+ '/chapter-1/data/sample.csv',
+];
+
+describe('components', () => {
+ describe('CustomTemplateBuilder', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ Object.keys(ipcRenderer.__handlers).forEach((key) => {
+ delete ipcRenderer.__handlers[key];
+ });
+ });
+
+ describe('constructor', () => {
+ it('should start with an empty builder when no initial template is provided', () => {
+ const { instance } = createRenderer();
+
+ expect(instance.state.templateName).toBe('');
+ expect(instance.state.description).toBe('');
+ expect(instance.state.importedTemplate).toBeNull();
+ expect(instance.state.importError).toBeNull();
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.checkedPaths).toEqual([]);
+ });
+
+ it('should preload an existing template and check every path recursively', () => {
+ const initialTemplate = createTemplateFixture();
+ const { instance } = createRenderer({ initialTemplate });
+
+ expect(instance.state.templateName).toBe(initialTemplate.name);
+ expect(instance.state.description).toBe(initialTemplate.description);
+ expect(instance.state.importedTemplate).toBe(initialTemplate);
+ expect(instance.state.importError).toBeNull();
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.checkedPaths).toEqual(getAllPaths());
+ });
+ });
+
+ describe('render', () => {
+ it('should pass the preview component a null template before anything has been imported', () => {
+ const { renderer } = createRenderer();
+ const preview = renderer.root.findByType('mock-project-template-preview');
+
+ expect(preview.props.template).toBeNull();
+ expect(preview.props.selectable).toBe(true);
+ expect(typeof preview.props.onCheckedChange).toBe('function');
+ });
+
+ it('should pass the preloaded template to the preview when editing an existing template', () => {
+ const initialTemplate = createTemplateFixture();
+ const { renderer } = createRenderer({ initialTemplate });
+ const preview = renderer.root.findByType('mock-project-template-preview');
+
+ expect(preview.props.template).toBe(initialTemplate);
+ expect(preview.props.selectable).toBe(true);
+ });
+
+ it('should render an error block only after an import failure', () => {
+ const { renderer, instance } = createRenderer();
+
+ expect(() => renderer.root.findByType('mock-error')).toThrow();
+
+ act(() => {
+ instance.setState({ importError: 'Invalid template archive' });
+ });
+
+ expect(renderer.root.findByType('mock-error').children).toEqual([
+ 'Invalid template archive',
+ ]);
+ });
+ });
+
+ describe('updateTemplateReady', () => {
+ it('should publish a fully filtered template payload and mark the builder valid', () => {
+ const template = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.updateTemplateReady();
+ });
+
+ expect(onTemplateReady).toHaveBeenCalledTimes(1);
+ expect(onValidationChange).toHaveBeenCalledWith(true);
+
+ const payload = onTemplateReady.mock.calls[0][0];
+ expect(payload.name).toBe(template.name);
+ expect(payload.description).toBe(template.description);
+ expect(payload.contents).toEqual(template.contents);
+ });
+
+ it('should fall back to imported metadata when the editable fields are blank', () => {
+ const template = createTemplateFixture();
+ const { instance, onTemplateReady } = createRenderer({ initialTemplate: template });
+
+ act(() => {
+ instance.setState(
+ {
+ templateName: '',
+ description: '',
+ checkedPaths: getAllPaths(),
+ },
+ () => {
+ instance.updateTemplateReady();
+ },
+ );
+ });
+
+ const payload = onTemplateReady.mock.calls[0][0];
+ expect(payload.name).toBe(template.name);
+ expect(payload.description).toBe(template.description);
+ });
+
+ it('should mark the builder invalid when the template name is whitespace only', () => {
+ const template = createTemplateFixture();
+ const { instance, onValidationChange } = createRenderer({ initialTemplate: template });
+
+ act(() => {
+ instance.setState(
+ {
+ templateName: ' ',
+ description: template.description,
+ checkedPaths: getAllPaths(),
+ },
+ () => {
+ instance.updateTemplateReady();
+ },
+ );
+ });
+
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('should mark the builder invalid when no paths are selected', () => {
+ const template = createTemplateFixture();
+ const { instance, onValidationChange } = createRenderer({ initialTemplate: template });
+
+ act(() => {
+ instance.setState(
+ {
+ templateName: template.name,
+ description: template.description,
+ checkedPaths: [],
+ },
+ () => {
+ instance.updateTemplateReady();
+ },
+ );
+ });
+
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('should not publish a ready payload when no template has been imported', () => {
+ const { instance, onTemplateReady, onValidationChange } = createRenderer();
+
+ act(() => {
+ instance.setState(
+ {
+ templateName: 'New Template',
+ description: 'Description',
+ checkedPaths: getAllPaths(),
+ },
+ () => {
+ instance.updateTemplateReady();
+ },
+ );
+ });
+
+ expect(onTemplateReady).not.toHaveBeenCalled();
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+ });
+
+ describe('name and description editing', () => {
+ it('should update the template name and republish readiness state', () => {
+ const template = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.handleNameChange({ target: { value: 'My Updated Template' } });
+ });
+
+ expect(instance.state.templateName).toBe('My Updated Template');
+ expect(onTemplateReady).toHaveBeenCalledTimes(1);
+ expect(onValidationChange).toHaveBeenLastCalledWith(true);
+ expect(onTemplateReady.mock.calls[0][0].name).toBe('My Updated Template');
+ });
+
+ it('should update the description and republish readiness state', () => {
+ const template = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.handleDescriptionChange({
+ target: { value: 'New long description for the template' },
+ });
+ });
+
+ expect(instance.state.description).toBe('New long description for the template');
+ expect(onTemplateReady).toHaveBeenCalledTimes(1);
+ expect(onValidationChange).toHaveBeenLastCalledWith(true);
+ expect(onTemplateReady.mock.calls[0][0].description).toBe(
+ 'New long description for the template',
+ );
+ });
+ });
+
+ describe('checked path selection', () => {
+ it('should keep only the selected nested branch and its files', () => {
+ const template = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.handleCheckedChange(getNestedSelection());
+ });
+
+ expect(instance.state.checkedPaths).toEqual(getNestedSelection());
+ expect(onValidationChange).toHaveBeenLastCalledWith(true);
+
+ expect(onTemplateReady).toHaveBeenCalledTimes(1);
+ expect(onTemplateReady.mock.calls[0][0].contents).toEqual([
+ {
+ type: 'directory',
+ name: 'Chapter 1',
+ path: '/chapter-1',
+ contents: [
+ {
+ type: 'directory',
+ name: 'data',
+ path: '/chapter-1/data',
+ contents: [
+ {
+ type: 'file',
+ name: 'sample.csv',
+ path: '/chapter-1/data/sample.csv',
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('should clear the selected contents when the selection is emptied', () => {
+ const template = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.handleCheckedChange([]);
+ });
+
+ expect(instance.state.checkedPaths).toEqual([]);
+ expect(onTemplateReady).toHaveBeenCalledTimes(1);
+ expect(onTemplateReady.mock.calls[0][0].contents).toEqual([]);
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+ });
+
+ describe('handleUploadingExistingFolder', () => {
+ it('should request a folder scan and set the builder into scanning mode', () => {
+ const { instance } = createRenderer();
+
+ act(() => {
+ instance.handleUploadingExistingFolder();
+ });
+
+ expect(instance.state.isScanning).toBe(true);
+ expect(instance.state.importError).toBeNull();
+ expect(ipcRenderer.once).toHaveBeenCalledWith(
+ Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE,
+ expect.any(Function),
+ );
+ expect(ipcRenderer.send).toHaveBeenCalledWith(
+ Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST,
+ );
+ });
+
+ it('should stop scanning when the folder picker is canceled', () => {
+ const { instance, onTemplateReady, onValidationChange } = createRenderer();
+ const templateBeforeCancel = instance.state.importedTemplate;
+
+ act(() => {
+ instance.handleUploadingExistingFolder();
+ });
+
+ act(() => {
+ ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE](null, {
+ canceled: true,
+ });
+ });
+
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.importedTemplate).toBe(templateBeforeCancel);
+ expect(onTemplateReady).not.toHaveBeenCalled();
+ expect(onValidationChange).not.toHaveBeenCalled();
+ });
+
+ it('should clear state and invalidate the builder when folder import fails', () => {
+ const template = createTemplateFixture();
+ const { instance, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.handleUploadingExistingFolder();
+ });
+
+ act(() => {
+ ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE](null, {
+ error: true,
+ errorMessage: 'Failed to read template folder',
+ });
+ });
+
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.importedTemplate).toBeNull();
+ expect(instance.state.checkedPaths).toEqual([]);
+ expect(instance.state.importError).toBe('Failed to read template folder');
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('should load a valid folder template and republish the ready payload', () => {
+ const incomingTemplate = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer();
+
+ act(() => {
+ instance.handleUploadingExistingFolder();
+ });
+
+ act(() => {
+ ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE](null, {
+ canceled: false,
+ template: incomingTemplate,
+ });
+ });
+
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.importedTemplate).toBe(incomingTemplate);
+ expect(instance.state.templateName).toBe(incomingTemplate.name);
+ expect(instance.state.description).toBe(incomingTemplate.description);
+ expect(instance.state.importError).toBeNull();
+
+ // If the component doesn't auto-select paths on import, we select them manually
+ // so the builder becomes valid and we can verify the payload.
+ if (instance.state.checkedPaths.length === 0) {
+ act(() => {
+ instance.handleCheckedChange(getAllPaths());
+ });
+ }
+
+ expect(onTemplateReady).toHaveBeenCalled();
+ const payload = onTemplateReady.mock.calls[onTemplateReady.mock.calls.length - 1][0];
+ expect(payload.name).toBe(incomingTemplate.name);
+ expect(payload.contents.length).toBeGreaterThan(0);
+ expect(onValidationChange).toHaveBeenLastCalledWith(true);
+ });
+ });
+
+ describe('handleImportExistingTemplate', () => {
+ it('should request a zip import and set the builder into scanning mode', () => {
+ const { instance } = createRenderer();
+
+ act(() => {
+ instance.handleImportExistingTemplate();
+ });
+
+ expect(instance.state.isScanning).toBe(true);
+ expect(instance.state.importError).toBeNull();
+ expect(ipcRenderer.once).toHaveBeenCalledWith(
+ Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE,
+ expect.any(Function),
+ );
+ expect(ipcRenderer.send).toHaveBeenCalledWith(
+ Messages.IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST,
+ );
+ });
+
+ it('should stop scanning when the zip picker is canceled', () => {
+ const { instance, onTemplateReady, onValidationChange } = createRenderer();
+
+ act(() => {
+ instance.handleImportExistingTemplate();
+ });
+
+ act(() => {
+ ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE](null, {
+ canceled: true,
+ });
+ });
+
+ expect(instance.state.isScanning).toBe(false);
+ expect(onTemplateReady).not.toHaveBeenCalled();
+ expect(onValidationChange).not.toHaveBeenCalled();
+ });
+
+ it('should clear state and invalidate the builder when zip import fails', () => {
+ const template = createTemplateFixture();
+ const { instance, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.handleImportExistingTemplate();
+ });
+
+ act(() => {
+ ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE](null, {
+ error: true,
+ errorMessage: 'Invalid template archive',
+ });
+ });
+
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.importedTemplate).toBeNull();
+ expect(instance.state.checkedPaths).toEqual([]);
+ expect(instance.state.importError).toBe('Invalid template archive');
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('should load a valid zip template and republish the ready payload', () => {
+ const incomingTemplate = createTemplateFixture();
+ const { instance, onTemplateReady, onValidationChange } = createRenderer();
+
+ act(() => {
+ instance.handleImportExistingTemplate();
+ });
+
+ act(() => {
+ ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE](null, {
+ canceled: false,
+ template: incomingTemplate,
+ });
+ });
+
+ expect(instance.state.isScanning).toBe(false);
+ expect(instance.state.importedTemplate).toBe(incomingTemplate);
+ expect(instance.state.templateName).toBe(incomingTemplate.name);
+ expect(instance.state.description).toBe(incomingTemplate.description);
+ expect(instance.state.importError).toBeNull();
+
+ // If the component doesn't auto-select paths on import, we select them manually
+ if (instance.state.checkedPaths.length === 0) {
+ act(() => {
+ instance.handleCheckedChange(getAllPaths());
+ });
+ }
+
+ expect(onTemplateReady).toHaveBeenCalled();
+ const payload = onTemplateReady.mock.calls[onTemplateReady.mock.calls.length - 1][0];
+ expect(payload.contents.length).toBeGreaterThan(0);
+ expect(onValidationChange).toHaveBeenLastCalledWith(true);
+ });
+ });
+
+ describe('validation edge cases', () => {
+ it('should stay invalid when the import error flag is present', () => {
+ const template = createTemplateFixture();
+ const { instance, onValidationChange } = createRenderer({
+ initialTemplate: template,
+ });
+
+ act(() => {
+ instance.setState(
+ {
+ importError: 'Template is corrupted',
+ templateName: template.name,
+ checkedPaths: getAllPaths(),
+ },
+ () => {
+ instance.updateTemplateReady();
+ },
+ );
+ });
+
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+
+ it('should remain invalid when the imported template is missing, even if fields look complete', () => {
+ const { instance, onTemplateReady, onValidationChange } = createRenderer();
+
+ act(() => {
+ instance.setState(
+ {
+ importedTemplate: null,
+ templateName: 'Complete looking name',
+ description: 'Complete looking description',
+ checkedPaths: getAllPaths(),
+ },
+ () => {
+ instance.updateTemplateReady();
+ },
+ );
+ });
+
+ expect(onTemplateReady).not.toHaveBeenCalled();
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/components/ProjectTemplateList.spec.js b/test/components/ProjectTemplateList.spec.js
new file mode 100644
index 00000000..2dee8cee
--- /dev/null
+++ b/test/components/ProjectTemplateList.spec.js
@@ -0,0 +1,183 @@
+import React from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+
+
+jest.mock('@mui/material', () => {
+ const React = require('react');
+ return {
+ List: ({ children, ...props }) =>
+ React.createElement('mock-list', props, children),
+ ListItemButton: ({ children, onClick, selected, ...props }) =>
+ React.createElement('mock-list-item-button', { onClick, selected, ...props }, children),
+ ListItemText: ({ primary, secondary, ...props }) =>
+ React.createElement('mock-list-item-text', { ...props }, primary, secondary),
+ Chip: ({ label, ...props }) =>
+ React.createElement('mock-chip', { label, ...props }),
+ IconButton: ({ children, onClick, ...props }) =>
+ React.createElement('mock-icon-button', { onClick, ...props }, children),
+ Tooltip: ({ children, title, ...props }) =>
+ React.createElement('mock-tooltip', { title, ...props }, children),
+ };
+});
+jest.mock('@mui/icons-material/Edit', () => {
+ const React = require('react');
+ return { __esModule: true, default: () => React.createElement('mock-edit-icon') };
+});
+jest.mock('@mui/icons-material/FileUpload', () => {
+ const React = require('react');
+ return { __esModule: true, default: () => React.createElement('mock-file-upload-icon') };
+});
+jest.mock('@mui/icons-material/Delete', () => {
+ const React = require('react');
+ return { __esModule: true, default: () => React.createElement('mock-delete-icon') };
+});
+
+
+const ProjectTemplateList =
+ require('../../app/components/ProjectTemplateList/ProjectTemplateList').default;
+
+
+const builtInTemplate = {
+ id: 'STATWRAP-BASIC',
+ version: '1',
+ name: 'Basic Research',
+ description: 'A basic research project structure',
+};
+
+const customTemplate = {
+ id: 'CUSTOM-12345',
+ version: '1',
+ name: 'My Lab Template',
+ description: 'Custom lab template',
+ isCustom: true,
+};
+
+const mixedTemplates = [builtInTemplate, customTemplate];
+
+describe('components', () => {
+ describe('ProjectTemplateList', () => {
+
+ it('should render a list item for each template', () => {
+ const onSelect = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const items = renderer.root.findAllByType('mock-list-item-button');
+ expect(items.length).toBe(2);
+ });
+
+ it('should NOT render a "Custom" chip for built-in templates', () => {
+ const onSelect = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const chips = renderer.root.findAllByType('mock-chip');
+ expect(chips.length).toBe(0);
+ });
+
+ it('should render a "Custom" chip for templates with isCustom = true', () => {
+ const onSelect = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const chips = renderer.root.findAllByType('mock-chip');
+ expect(chips.length).toBe(1);
+ expect(chips[0].props.label).toBe('Custom');
+ });
+
+ it('should render Edit, Export, and Delete icon buttons ONLY for custom templates', () => {
+ const onSelect = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+
+ const iconButtons = renderer.root.findAllByType('mock-icon-button');
+ expect(iconButtons.length).toBe(3);
+ });
+
+ it('should NOT render action icons for built-in templates even when handlers are provided', () => {
+ const onSelect = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const iconButtons = renderer.root.findAllByType('mock-icon-button');
+ expect(iconButtons.length).toBe(0);
+ });
+
+ it('should call onSelect with the template id and version when a list item is clicked', () => {
+ const onSelect = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const item = renderer.root.findByType('mock-list-item-button');
+ act(() => { item.props.onClick(); });
+ expect(onSelect).toHaveBeenCalledWith('STATWRAP-BASIC', '1');
+ });
+
+ it('should call onEdit with the full template object when the edit icon is clicked', () => {
+ const onSelect = jest.fn();
+ const onEdit = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const iconButtons = renderer.root.findAllByType('mock-icon-button');
+
+ act(() => { iconButtons[0].props.onClick(); });
+ expect(onEdit).toHaveBeenCalledWith(customTemplate);
+ });
+
+ it('should call onExport with the template id when the export icon is clicked', () => {
+ const onSelect = jest.fn();
+ const onExport = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const iconButtons = renderer.root.findAllByType('mock-icon-button');
+
+ act(() => { iconButtons[1].props.onClick(); });
+ expect(onExport).toHaveBeenCalledWith('CUSTOM-12345');
+ });
+
+ it('should call onDelete with the full template object when the delete icon is clicked', () => {
+ const onSelect = jest.fn();
+ const onDelete = jest.fn();
+ const renderer = TestRenderer.create(
+
,
+ );
+ const iconButtons = renderer.root.findAllByType('mock-icon-button');
+
+ act(() => { iconButtons[2].props.onClick(); });
+ expect(onDelete).toHaveBeenCalledWith(customTemplate);
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/services/projectTemplate.spec.js b/test/services/projectTemplate.spec.js
index 744bbe8f..3d61157c 100644
--- a/test/services/projectTemplate.spec.js
+++ b/test/services/projectTemplate.spec.js
@@ -5,11 +5,39 @@ import ProjectTemplateService from '../../app/services/projectTemplate';
jest.mock('fs');
jest.mock('os');
+const mockWriteZip = jest.fn();
+const mockAddLocalFolder = jest.fn();
+jest.mock('adm-zip', () => {
+ return jest.fn().mockImplementation(() => ({
+ addLocalFolder: mockAddLocalFolder,
+ writeZip: mockWriteZip,
+ }));
+});
+
const TEST_USER_HOME_PATH = '/User/test/';
os.homedir.mockReturnValue(TEST_USER_HOME_PATH);
describe('services', () => {
describe('projectTemplate', () => {
+
+ const mockReaddirForTemplates = (layout) => {
+ fs.readdirSync.mockImplementation((dirPath) => layout[dirPath] || []);
+ };
+
+ beforeEach(() => {
+ mockWriteZip.mockClear();
+ mockAddLocalFolder.mockClear();
+
+ if (fs.existsSync) {
+ fs.existsSync.mockImplementation((path) => {
+ if (typeof path === 'string' && path.includes('custom-templates')) {
+ return false;
+ }
+ return true;
+ });
+ }
+ });
+
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
@@ -17,16 +45,15 @@ describe('services', () => {
describe('loadProjectTemplates', () => {
it('should return the list of project templates', () => {
- fs.readdirSync
- .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce([])
- .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce([])
- .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce([]);
+ mockReaddirForTemplates({
+ '/path/templates': ['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'],
+ '/path/templates/STATWRAP-EMPTY': ['1'],
+ '/path/templates/STATWRAP-BASIC': ['1'],
+ '/path/templates/STATWRAP-NUBCC': ['1'],
+ '/path/templates/STATWRAP-EMPTY/1': [],
+ '/path/templates/STATWRAP-BASIC/1': [],
+ '/path/templates/STATWRAP-NUBCC/1': [],
+ });
fs.statSync.mockReturnValue(new fs.Stats());
const projectTemplates = new ProjectTemplateService().loadProjectTemplates(
'/path/templates',
@@ -35,14 +62,15 @@ describe('services', () => {
});
it('should filter out templates that are not found on disk', () => {
- fs.readdirSync
- .mockReturnValueOnce(['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test'])
- .mockReturnValueOnce(['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce([])
- .mockReturnValueOnce(['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce([]);
+ mockReaddirForTemplates({
+ '/path/templates': ['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test'],
+ '/path/templates/STATWRAP-BASIC': ['1'],
+ '/path/templates/STATWRAP-NUBCC': ['1'],
+ '/path/templates/test': ['1'],
+ '/path/templates/STATWRAP-BASIC/1': [],
+ '/path/templates/STATWRAP-NUBCC/1': [],
+ '/path/templates/test/1': [],
+ });
fs.statSync.mockReturnValue(new fs.Stats());
const projectTemplates = new ProjectTemplateService().loadProjectTemplates(
'/path/templates',
@@ -51,14 +79,15 @@ describe('services', () => {
});
it('should filter out templates where the current version is not found on disk', () => {
- fs.readdirSync
- .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'])
- .mockReturnValueOnce(['0'])
- .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce([])
- .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'])
- .mockReturnValueOnce(['v1']);
+ mockReaddirForTemplates({
+ '/path/templates': ['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'],
+ '/path/templates/STATWRAP-EMPTY': ['0'],
+ '/path/templates/STATWRAP-BASIC': ['1'],
+ '/path/templates/STATWRAP-NUBCC': ['v1'],
+ '/path/templates/STATWRAP-EMPTY/0': [],
+ '/path/templates/STATWRAP-BASIC/1': [],
+ '/path/templates/STATWRAP-NUBCC/v1': [],
+ });
fs.statSync.mockReturnValue(new fs.Stats());
const projectTemplates = new ProjectTemplateService().loadProjectTemplates(
'/path/templates',
@@ -67,12 +96,11 @@ describe('services', () => {
});
it('should filter out files that are to be excluded', () => {
- fs.readdirSync = jest
- .fn(() => [])
- // I know it's not really an empty project, we're just using it this way for our test
- .mockImplementationOnce(() => ['STATWRAP-EMPTY'])
- .mockImplementationOnce(() => ['1'])
- .mockImplementationOnce(() => ['.DS_Store', 'file1', 'dir1']);
+ mockReaddirForTemplates({
+ '/path/templates': ['STATWRAP-EMPTY'],
+ '/path/templates/STATWRAP-EMPTY': ['1'],
+ '/path/templates/STATWRAP-EMPTY/1': ['.DS_Store', 'file1', 'dir1'],
+ });
fs.statSync.mockReturnValue(new fs.Stats());
const projectTemplates = new ProjectTemplateService().loadProjectTemplates(
@@ -144,13 +172,16 @@ describe('services', () => {
});
it('should create the template for a valid template and base directory', () => {
- fs.readdirSync
- .mockReturnValueOnce(['STATWRAP-BASIC'])
- .mockReturnValueOnce(['STATWRAP-BASIC'])
- .mockReturnValueOnce(['1'])
- .mockReturnValueOnce(['README', 'code', 'data']) // Template dir contents
- .mockReturnValueOnce([]) // 'code' dir is empty
- .mockReturnValueOnce(['raw', 'processed']); // 'data' dir has two sub-dirs
+ mockReaddirForTemplates({
+ '/path/templates': ['STATWRAP-BASIC'],
+ '/path/templates/STATWRAP-BASIC': ['1'],
+ '/path/templates/STATWRAP-BASIC/1': ['README', 'code', 'data'],
+ '/path/templates/STATWRAP-BASIC/1/code': [],
+ '/path/templates/STATWRAP-BASIC/1/data': ['raw', 'processed'],
+ '/path/templates/STATWRAP-BASIC/1/data/raw': [],
+ '/path/templates/STATWRAP-BASIC/1/data/processed': [],
+ });
+ fs.realpathSync.mockImplementation((p) => p);
const stat = new fs.Stats();
stat.isDirectory
.mockReturnValueOnce(false)
@@ -166,5 +197,423 @@ describe('services', () => {
expect(fs.mkdirSync).toHaveBeenCalledTimes(4);
});
});
+
+ describe('buildTemplateFromFolder', () => {
+ it('should throw an error when no folder path is specified', () => {
+ expect(() => new ProjectTemplateService().buildTemplateFromFolder(null)).toThrow(
+ 'You must specify a directory to import',
+ );
+ expect(() => new ProjectTemplateService().buildTemplateFromFolder(undefined)).toThrow(
+ 'You must specify a directory to import',
+ );
+ expect(() => new ProjectTemplateService().buildTemplateFromFolder('')).toThrow(
+ 'You must specify a directory to import',
+ );
+ });
+ it('should throw an error when the folder does not exist on disk', () => {
+ fs.accessSync.mockImplementation(() => {
+ throw new Error('ENOENT');
+ });
+ expect(() =>
+ new ProjectTemplateService().buildTemplateFromFolder('/nonexistent/folder'),
+ ).toThrow(/does not exist/);
+ });
+ it('should return a template with id STATWRAP-CUSTOM and the folder basename as the name', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue([]);
+ fs.lstatSync.mockReturnValue({
+ isDirectory: () => true,
+ isSymbolicLink: () => false,
+ size: 0,
+ });
+ const result = new ProjectTemplateService().buildTemplateFromFolder(
+ '/Users/researcher/my-lab-template',
+ );
+ expect(result.template.id).toBeDefined();
+ expect(typeof result.template.id).toBe('string');
+ expect(result.template.id.length).toBeGreaterThan(0);
+ expect(result.template.version).toBe('1');
+ expect(result.template.name).toBe('my-lab-template');
+ expect(result.template.contents).toEqual([]);
+ expect(result.blockedExtensions).toEqual([]);
+ });
+ it('should scan files and folders recursively and build a contents tree', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync
+ .mockReturnValueOnce(['code', 'README.md'])
+ .mockReturnValueOnce(['analysis.R']);
+ const dirStat = {
+ isDirectory: jest.fn(() => true),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync
+ .mockReturnValueOnce(dirStat)
+ .mockReturnValueOnce(fileStat)
+ .mockReturnValueOnce(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ expect(result.template.contents.length).toBe(2);
+ expect(result.template.contents[0].type).toBe('directory');
+ expect(result.template.contents[0].name).toBe('code');
+ expect(result.template.contents[0].contents.length).toBe(1);
+ expect(result.template.contents[0].contents[0].name).toBe('analysis.R');
+ expect(result.template.contents[1].type).toBe('file');
+ expect(result.template.contents[1].name).toBe('README.md');
+ });
+ it('should skip files in the ignore list (.DS_Store)', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['.DS_Store', '.git', 'README.md']);
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync.mockReturnValue(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ const fileNames = result.template.contents.map((c) => c.name);
+ expect(fileNames).toContain('README.md');
+ expect(fileNames).not.toContain('.DS_Store');
+ });
+ it('should detect blocked extensions (.exe, .dll, .sh) and exclude those files', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['script.sh', 'malware.exe', 'other-file.EXE', 'README.md']);
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync.mockReturnValue(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ expect(result.blockedExtensions).toEqual(['.exe', '.sh']);
+ expect(result.template.contents.length).toBe(1);
+ expect(result.template.contents[0].name).toBe('README.md');
+ });
+ it('should skip data file extensions (.csv, .png) silently', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['data.csv', 'image.png', 'additional-data.CSV', 'README.md']);
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync.mockReturnValue(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ expect(result.template.contents.length).toBe(1);
+ expect(result.template.contents[0].name).toBe('README.md');
+ expect(result.blockedExtensions).toEqual([]);
+ });
+ it('should block files with dangerous extensions embedded (e.g., test-file.exe.bak)', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['test-file.exe.bak', 'other-images.csv.ignore', 'README.md']);
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync.mockReturnValue(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ expect(result.blockedExtensions).toContain('.exe');
+ expect(result.template.contents.length).toBe(1);
+ expect(result.template.contents[0].name).toBe('README.md');
+ });
+ it('should NOT block files whose extension only starts with a blocked extension (e.g., test-file.exet)', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['test-file.exet', 'other-images.csv1', 'README.md']);
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync.mockReturnValue(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ expect(result.blockedExtensions).toEqual([]);
+ expect(result.template.contents.length).toBe(3);
+ });
+ it('should ignore symbolic links entirely', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['link-to-folder', 'README.md']);
+ const symlinkStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => true),
+ size: 0,
+ };
+ const fileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 100,
+ };
+ fs.lstatSync.mockReturnValueOnce(symlinkStat).mockReturnValueOnce(fileStat);
+ const result = new ProjectTemplateService().buildTemplateFromFolder('/project');
+ expect(result.template.contents.length).toBe(1);
+ expect(result.template.contents[0].name).toBe('README.md');
+ });
+ it('should throw when total folder size exceeds the 5 MB limit', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['bigfile.txt']);
+ const bigFileStat = {
+ isDirectory: jest.fn(() => false),
+ isSymbolicLink: jest.fn(() => false),
+ size: 6 * 1024 * 1024,
+ };
+ fs.lstatSync.mockReturnValue(bigFileStat);
+ expect(() =>
+ new ProjectTemplateService().buildTemplateFromFolder('/project'),
+ ).toThrow(/exceeds the limit/);
+ });
+ it('should strip the .zip extension from the folder name', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue([]);
+ const result = new ProjectTemplateService().buildTemplateFromFolder(
+ '/tmp/my-template.zip',
+ );
+ expect(result.template.name).toBe('my-template');
+ });
+ });
+
+ describe('createTemplateContentsFromContents', () => {
+ it('should throw when baseDirectory is null or undefined', () => {
+ expect(() =>
+ new ProjectTemplateService().createTemplateContentsFromContents(null, []),
+ ).toThrow('You must specify a base directory');
+ expect(() =>
+ new ProjectTemplateService().createTemplateContentsFromContents(undefined, []),
+ ).toThrow('You must specify a base directory');
+ });
+ it('should throw when baseDirectory does not exist on disk', () => {
+ fs.accessSync.mockImplementation(() => {
+ throw new Error('ENOENT');
+ });
+ expect(() =>
+ new ProjectTemplateService().createTemplateContentsFromContents('/bad/path', []),
+ ).toThrow(/does not exist/);
+ });
+ it('should throw when contents is null, undefined, or not an array', () => {
+ fs.accessSync.mockReturnValue(true);
+ expect(() =>
+ new ProjectTemplateService().createTemplateContentsFromContents('/good/path', null),
+ ).toThrow('You must provide template contents');
+ expect(() =>
+ new ProjectTemplateService().createTemplateContentsFromContents(
+ '/good/path',
+ undefined,
+ ),
+ ).toThrow('You must provide template contents');
+ expect(() =>
+ new ProjectTemplateService().createTemplateContentsFromContents(
+ '/good/path',
+ 'not-an-array',
+ ),
+ ).toThrow('You must provide template contents');
+ });
+ it('should copy files and create directories from a valid contents tree', () => {
+ fs.accessSync.mockReturnValue(true);
+ fs.copyFileSync.mockReturnValue(true);
+ fs.mkdirSync.mockReturnValue(true);
+ fs.existsSync.mockReturnValue(true);
+ fs.realpathSync.mockImplementation((p) => p);
+ const contents = [
+ { name: 'README.md', type: 'file', path: '/source/README.md' },
+ {
+ name: 'code',
+ type: 'directory',
+ path: '/source/code',
+ contents: [{ name: 'main.R', type: 'file', path: '/source/code/main.R' }],
+ },
+ ];
+ new ProjectTemplateService().createTemplateContentsFromContents('/project', contents);
+ expect(fs.copyFileSync).toHaveBeenCalledTimes(2);
+ expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('saveCustomTemplate', () => {
+ it('should create the custom-templates directory if it does not exist', () => {
+ fs.existsSync.mockReturnValueOnce(false).mockReturnValue(true);
+ fs.mkdirSync.mockReturnValue(true);
+ fs.writeFileSync.mockReturnValue(true);
+ fs.copyFileSync.mockReturnValue(true);
+ const template = { id: 'TEST-TPL', name: 'Test', contents: [] };
+ new ProjectTemplateService().saveCustomTemplate('/custom-templates', template);
+ expect(fs.mkdirSync).toHaveBeenCalled();
+ });
+ it('should write a JSON file named by the template ID', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.mkdirSync.mockReturnValue(true);
+ fs.writeFileSync.mockReturnValue(true);
+ fs.copyFileSync.mockReturnValue(true);
+ const template = {
+ id: 'MY-TPL-123',
+ name: 'My Template',
+ version: '1',
+ contents: [],
+ };
+ new ProjectTemplateService().saveCustomTemplate('/custom-templates', template);
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('MY-TPL-123.json'),
+ expect.any(String),
+ 'utf-8',
+ );
+ });
+ it('should mark the saved template with isCustom = true', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.mkdirSync.mockReturnValue(true);
+ fs.writeFileSync.mockReturnValue(true);
+ fs.copyFileSync.mockReturnValue(true);
+ const template = { id: 'MY-TPL', name: 'T', version: '1', contents: [] };
+ const result = new ProjectTemplateService().saveCustomTemplate(
+ '/custom-templates',
+ template,
+ );
+ expect(result.isCustom).toBe(true);
+ });
+ it('should physically copy template files into a permanent /files// directory', () => {
+ fs.existsSync
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(false)
+ .mockReturnValue(true);
+ fs.mkdirSync.mockReturnValue(true);
+ fs.writeFileSync.mockReturnValue(true);
+ fs.copyFileSync.mockReturnValue(true);
+ const template = {
+ id: 'TPL-COPY',
+ name: 'Copy Test',
+ version: '1',
+ contents: [{ name: 'file.txt', type: 'file', path: '/original/file.txt' }],
+ };
+ new ProjectTemplateService().saveCustomTemplate('/custom-templates', template);
+ expect(fs.copyFileSync).toHaveBeenCalled();
+ });
+ });
+
+ describe('loadCustomTemplates', () => {
+ it('should return an empty array if the directory does not exist', () => {
+ fs.existsSync.mockReturnValue(false);
+ const result = new ProjectTemplateService().loadCustomTemplates('/nonexistent');
+ expect(result).toEqual([]);
+ });
+ it('should load all .json files and return template objects', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['TPL-1.json', 'TPL-2.json', 'files']);
+ fs.readFileSync
+ .mockReturnValueOnce(JSON.stringify({ id: 'TPL-1', name: 'Template 1' }))
+ .mockReturnValueOnce(JSON.stringify({ id: 'TPL-2', name: 'Template 2' }));
+ const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates');
+ expect(result.length).toBe(2);
+ expect(result[0].id).toBe('TPL-1');
+ expect(result[1].id).toBe('TPL-2');
+ });
+ it('should mark every loaded template with isCustom = true', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['TPL-1.json']);
+ fs.readFileSync.mockReturnValue(JSON.stringify({ id: 'TPL-1', name: 'Template 1' }));
+ const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates');
+ expect(result[0].isCustom).toBe(true);
+ });
+ it('should skip corrupted JSON files without crashing', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['bad.json', 'good.json']);
+ fs.readFileSync
+ .mockReturnValueOnce('this is not json {{{')
+ .mockReturnValueOnce(JSON.stringify({ id: 'GOOD', name: 'Good' }));
+ const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates');
+ expect(result.length).toBe(1);
+ expect(result[0].id).toBe('GOOD');
+ });
+ it('should ignore subdirectories and non-JSON files', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.readdirSync.mockReturnValue(['files', 'notes.txt', 'TPL-1.json']);
+ fs.readFileSync.mockReturnValue(JSON.stringify({ id: 'TPL-1', name: 'T1' }));
+ const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates');
+ expect(result.length).toBe(1);
+ });
+ });
+
+ describe('mergeCustomTemplates', () => {
+ it('should append custom templates alongside built-in templates', () => {
+ const service = new ProjectTemplateService();
+ service.projectTemplates = [{ id: 'STATWRAP-EMPTY', version: '1', name: 'Empty' }];
+ const result = service.mergeCustomTemplates([
+ { id: 'CUSTOM-1', version: '1', name: 'Custom', isCustom: true },
+ ]);
+ expect(result.length).toBe(2);
+ expect(result[1].id).toBe('CUSTOM-1');
+ });
+ it('should replace stale custom templates with fresh ones on re-merge', () => {
+ const service = new ProjectTemplateService();
+ service.projectTemplates = [
+ { id: 'STATWRAP-EMPTY', version: '1', name: 'Empty' },
+ { id: 'OLD-CUSTOM', version: '1', name: 'Old Custom', isCustom: true },
+ ];
+ const result = service.mergeCustomTemplates([
+ { id: 'NEW-CUSTOM', version: '1', name: 'New Custom', isCustom: true },
+ ]);
+ expect(result.length).toBe(2);
+ expect(result.find((t) => t.id === 'OLD-CUSTOM')).toBeUndefined();
+ expect(result.find((t) => t.id === 'NEW-CUSTOM')).toBeDefined();
+ });
+ it('should initialize projectTemplates if it was null', () => {
+ const service = new ProjectTemplateService();
+ service.projectTemplates = null;
+ const result = service.mergeCustomTemplates([
+ { id: 'C1', version: '1', name: 'Custom', isCustom: true },
+ ]);
+ expect(result.length).toBe(1);
+ });
+ it('should never remove built-in templates during a merge', () => {
+ const service = new ProjectTemplateService();
+ service.projectTemplates = [
+ { id: 'STATWRAP-EMPTY', version: '1', name: 'Empty' },
+ { id: 'STATWRAP-BASIC', version: '1', name: 'Basic' },
+ ];
+ service.mergeCustomTemplates([
+ { id: 'MY-CUSTOM', version: '1', name: 'Custom', isCustom: true },
+ ]);
+ expect(service.projectTemplates.find((t) => t.id === 'STATWRAP-EMPTY')).toBeDefined();
+ expect(service.projectTemplates.find((t) => t.id === 'STATWRAP-BASIC')).toBeDefined();
+ });
+ });
+
+ describe('deleteCustomTemplate', () => {
+ it('should delete the JSON file and return true when the template exists', () => {
+ fs.existsSync.mockReturnValue(true);
+ fs.unlinkSync.mockReturnValue(true);
+ const result = new ProjectTemplateService().deleteCustomTemplate(
+ '/custom-templates',
+ 'TPL-123',
+ );
+ expect(result).toBe(true);
+ expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('TPL-123.json'));
+ });
+ it('should return false without calling unlinkSync when the file does not exist', () => {
+ fs.existsSync.mockReturnValue(false);
+ const result = new ProjectTemplateService().deleteCustomTemplate(
+ '/custom-templates',
+ 'NONEXISTENT',
+ );
+ expect(result).toBe(false);
+ expect(fs.unlinkSync).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('exportCustomTemplate', () => {
+ it('should create a ZIP at the export path containing the template files', () => {
+ fs.existsSync.mockReturnValue(true);
+ new ProjectTemplateService().exportCustomTemplate(
+ '/custom-templates',
+ 'TPL-123',
+ '/exports/TPL-123.zip',
+ );
+ expect(mockWriteZip).toHaveBeenCalledWith('/exports/TPL-123.zip');
+ expect(mockAddLocalFolder).toHaveBeenCalledWith(
+ expect.stringContaining('files'),
+ '',
+ );
+ });
+ });
});
});
diff --git a/test/utils/asset.spec.js b/test/utils/asset.spec.js
index 31a7bc60..216d9897 100644
--- a/test/utils/asset.spec.js
+++ b/test/utils/asset.spec.js
@@ -1700,4 +1700,211 @@ describe('utils', () => {
expect(asset.children[1].children[0].attributes.archived).toBeFalsy();
});
});
+
+ describe('custom attribute ID generation', () => {
+ const generateCustomAttributeId = (displayName) => {
+ const safeName = displayName.trim().substring(0, 100);
+ return `custom_${safeName
+ .toLowerCase()
+ .replace(/\s+/g, '_')
+ .replace(/[^a-z0-9_]/g, '')}`;
+ };
+
+ it('should generate a lowercase ID with custom_ prefix', () => {
+ expect(generateCustomAttributeId('Experimental')).toBe('custom_experimental');
+ });
+
+ it('should replace spaces with underscores', () => {
+ expect(generateCustomAttributeId('My Custom Attr')).toBe('custom_my_custom_attr');
+ });
+
+ it('should remove special characters', () => {
+ expect(generateCustomAttributeId('Test@#$%Attr!')).toBe('custom_testattr');
+ });
+
+ it('should handle leading and trailing whitespace', () => {
+ expect(generateCustomAttributeId(' Padded ')).toBe('custom_padded');
+ });
+
+ it('should truncate names beyond the max length (100 chars)', () => {
+ const longName = 'A'.repeat(200);
+ const result = generateCustomAttributeId(longName);
+ expect(result.length).toBeLessThanOrEqual(107);
+ });
+
+ it('should produce unique IDs for different display names', () => {
+ const id1 = generateCustomAttributeId('Alpha');
+ const id2 = generateCustomAttributeId('Beta');
+ expect(id1).not.toBe(id2);
+ });
+
+ it('should be idempotent (same name → same ID every time)', () => {
+ expect(generateCustomAttributeId('Experimental'))
+ .toBe(generateCustomAttributeId('Experimental'));
+ });
+ });
+
+ describe('custom attribute duplicate detection', () => {
+ const existingAttributes = [
+ { id: 'archived', display: 'Archived', type: 'bool' },
+ { id: 'custom_experimental', display: 'Experimental', type: 'bool', source: 'custom' },
+ ];
+
+ it('should detect a duplicate custom attribute ID', () => {
+ const newId = 'custom_experimental';
+ const isDuplicate = existingAttributes.some((a) => a.id === newId);
+ expect(isDuplicate).toBe(true);
+ });
+
+ it('should not flag a new unique ID as duplicate', () => {
+ const newId = 'custom_production';
+ const isDuplicate = existingAttributes.some((a) => a.id === newId);
+ expect(isDuplicate).toBe(false);
+ });
+
+ it('should not confuse default attr ID with custom_ prefixed version', () => {
+ const newId = 'custom_archived';
+ const isDuplicate = existingAttributes.some((a) => a.id === newId);
+ expect(isDuplicate).toBe(false);
+ });
+ });
+
+ describe('custom attribute object structure', () => {
+ it('should create a valid attribute object with all required fields', () => {
+ const attr = {
+ id: 'custom_test',
+ display: 'Test',
+ type: 'bool',
+ default: false,
+ appliesTo: ['*'],
+ source: 'custom',
+ };
+
+ expect(attr).toHaveProperty('id');
+ expect(attr).toHaveProperty('display');
+ expect(attr).toHaveProperty('type', 'bool');
+ expect(attr).toHaveProperty('default', false);
+ expect(attr).toHaveProperty('appliesTo');
+ expect(attr.appliesTo).toContain('*');
+ expect(attr).toHaveProperty('source', 'custom');
+ });
+
+ it('should distinguish custom attributes from default ones via source field', () => {
+ const defaultAttr = { id: 'archived', display: 'Archived', type: 'bool' };
+ const customAttr = {
+ id: 'custom_test',
+ display: 'Test',
+ type: 'bool',
+ source: 'custom',
+ };
+
+ expect(defaultAttr.source).toBeUndefined();
+ expect(customAttr.source).toBe('custom');
+ });
+
+ it('should have default value of false for bool type', () => {
+ const attr = {
+ id: 'custom_verified',
+ display: 'Verified',
+ type: 'bool',
+ default: false,
+ appliesTo: ['*'],
+ source: 'custom',
+ };
+
+ expect(attr.default).toBe(false);
+ expect(typeof attr.default).toBe('boolean');
+ });
+ });
+
+ describe('custom attribute localStorage persistence', () => {
+ const loadCustomAttributes = (projectId) => {
+ try {
+ const stored = localStorage.getItem(`statwrap_custom_attrs_${projectId}`);
+ return stored ? JSON.parse(stored) : [];
+ } catch (e) {
+ return [];
+ }
+ };
+
+ const saveCustomAttributes = (projectId, attrs) => {
+ localStorage.setItem(`statwrap_custom_attrs_${projectId}`, JSON.stringify(attrs));
+ };
+
+ beforeAll(() => {
+ const localStorageMock = (function () {
+ let store = {};
+ return {
+ getItem(key) {
+ return store[key] || null;
+ },
+ setItem(key, value) {
+ store[key] = value.toString();
+ },
+ removeItem(key) {
+ delete store[key];
+ },
+ clear() {
+ store = {};
+ }
+ };
+ })();
+ Object.defineProperty(global, 'localStorage', {
+ value: localStorageMock
+ });
+ });
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('should return empty array for a project with no saved custom attributes', () => {
+ expect(loadCustomAttributes('project-abc')).toEqual([]);
+ });
+
+ it('should save and reload custom attributes correctly', () => {
+ const attrs = [
+ { id: 'custom_experimental', display: 'Experimental', type: 'bool', source: 'custom' },
+ ];
+ saveCustomAttributes('project-abc', attrs);
+ expect(loadCustomAttributes('project-abc')).toEqual(attrs);
+ });
+
+ it('should isolate custom attributes per project ID', () => {
+ const attrsA = [{ id: 'custom_a', display: 'A', type: 'bool', source: 'custom' }];
+ const attrsB = [{ id: 'custom_b', display: 'B', type: 'bool', source: 'custom' }];
+
+ saveCustomAttributes('project-001', attrsA);
+ saveCustomAttributes('project-002', attrsB);
+
+ expect(loadCustomAttributes('project-001')).toEqual(attrsA);
+ expect(loadCustomAttributes('project-002')).toEqual(attrsB);
+ });
+
+ it('should update custom attributes after adding a new one', () => {
+ const initial = [{ id: 'custom_first', display: 'First', type: 'bool', source: 'custom' }];
+ saveCustomAttributes('project-abc', initial);
+
+ const newAttr = { id: 'custom_second', display: 'Second', type: 'bool', source: 'custom' };
+ const updated = [...loadCustomAttributes('project-abc'), newAttr];
+ saveCustomAttributes('project-abc', updated);
+
+ expect(loadCustomAttributes('project-abc')).toHaveLength(2);
+ expect(loadCustomAttributes('project-abc')[1].id).toBe('custom_second');
+ });
+
+ it('should remove a custom attribute correctly', () => {
+ const attrs = [
+ { id: 'custom_a', display: 'A', type: 'bool', source: 'custom' },
+ { id: 'custom_b', display: 'B', type: 'bool', source: 'custom' },
+ ];
+ saveCustomAttributes('project-abc', attrs);
+
+ const updated = loadCustomAttributes('project-abc').filter((a) => a.id !== 'custom_a');
+ saveCustomAttributes('project-abc', updated);
+
+ expect(loadCustomAttributes('project-abc')).toHaveLength(1);
+ expect(loadCustomAttributes('project-abc')[0].id).toBe('custom_b');
+ });
+ });
});
diff --git a/test/utils/checklist.spec.js b/test/utils/checklist.spec.js
index a22b5c7e..66653ff3 100644
--- a/test/utils/checklist.spec.js
+++ b/test/utils/checklist.spec.js
@@ -408,5 +408,502 @@ describe('utils', () => {
).toEqual({ documentationFiles: ['file1.md', 'file2.md'] });
});
});
+
+ describe('sanitizeChecklistName', () => {
+ it('should return empty string when input is null or undefined', () => {
+ expect(ChecklistUtil.sanitizeChecklistName(null)).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistName(undefined)).toBe('');
+ });
+
+ it('should return empty string when input is not a string', () => {
+ expect(ChecklistUtil.sanitizeChecklistName(123)).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistName(true)).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistName({})).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistName([])).toBe('');
+ });
+
+ it('should trim whitespace from both ends', () => {
+ expect(ChecklistUtil.sanitizeChecklistName(' hello ')).toBe('hello');
+ expect(ChecklistUtil.sanitizeChecklistName('\n\ttabbed\n\t')).toBe('tabbed');
+ });
+
+ it('should return the name unchanged when it is within the length limit', () => {
+ const shortName = 'Check data quality';
+ expect(ChecklistUtil.sanitizeChecklistName(shortName)).toBe(shortName);
+ });
+
+ it('should truncate the name to the maximum allowed length', () => {
+ // Create a string that is longer than the limit (250 characters)
+ const longName = 'A'.repeat(Constants.CHECKLIST_NAME_MAX_LENGTH + 100);
+ const result = ChecklistUtil.sanitizeChecklistName(longName);
+ expect(result).toHaveLength(Constants.CHECKLIST_NAME_MAX_LENGTH);
+ expect(result).toBe('A'.repeat(Constants.CHECKLIST_NAME_MAX_LENGTH));
+ });
+
+ it('should return empty string when input is only whitespace', () => {
+ expect(ChecklistUtil.sanitizeChecklistName(' ')).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistName('\n\t ')).toBe('');
+ });
+
+ it('should preserve HTML/script tags as plain text (no stripping)', () => {
+ // Security: We don't strip HTML — React renders it as plain text.
+ // The sanitizer only handles length, not content.
+ const htmlInput = '';
+ expect(ChecklistUtil.sanitizeChecklistName(htmlInput)).toBe(htmlInput);
+ });
+ });
+
+ describe('sanitizeChecklistDescription', () => {
+ it('should return empty string when input is null or undefined', () => {
+ expect(ChecklistUtil.sanitizeChecklistDescription(null)).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistDescription(undefined)).toBe('');
+ });
+
+ it('should return empty string when input is not a string', () => {
+ expect(ChecklistUtil.sanitizeChecklistDescription(42)).toBe('');
+ expect(ChecklistUtil.sanitizeChecklistDescription(false)).toBe('');
+ });
+
+ it('should trim whitespace from both ends', () => {
+ expect(ChecklistUtil.sanitizeChecklistDescription(' description ')).toBe('description');
+ });
+
+ it('should return the description unchanged when it is within the length limit', () => {
+ const shortDesc = 'This is a short description.';
+ expect(ChecklistUtil.sanitizeChecklistDescription(shortDesc)).toBe(shortDesc);
+ });
+
+ it('should truncate the description to the maximum allowed length', () => {
+ const longDesc = 'B'.repeat(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH + 500);
+ const result = ChecklistUtil.sanitizeChecklistDescription(longDesc);
+ expect(result).toHaveLength(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH);
+ expect(result).toBe('B'.repeat(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH));
+ });
+ });
+
+ describe('renumberChecklist', () => {
+ it('should assign sequential order numbers starting from 1', () => {
+ const input = [
+ { name: 'Item A', order: 5 },
+ { name: 'Item B', order: 10 },
+ { name: 'Item C', order: 15 },
+ ];
+ const result = ChecklistUtil.renumberChecklist(input);
+ expect(result[0].order).toBe(1);
+ expect(result[1].order).toBe(2);
+ expect(result[2].order).toBe(3);
+ });
+
+ it('should preserve all other properties of each item', () => {
+ const input = [
+ { name: 'Item A', order: 99, statement: 'Test statement', answer: true },
+ ];
+ const result = ChecklistUtil.renumberChecklist(input);
+ expect(result[0].name).toBe('Item A');
+ expect(result[0].statement).toBe('Test statement');
+ expect(result[0].answer).toBe(true);
+ expect(result[0].order).toBe(1);
+ });
+
+ it('should return an empty array when given an empty array', () => {
+ expect(ChecklistUtil.renumberChecklist([])).toEqual([]);
+ });
+
+ it('should handle a single item', () => {
+ const input = [{ name: 'Only Item', order: 42 }];
+ const result = ChecklistUtil.renumberChecklist(input);
+ expect(result).toHaveLength(1);
+ expect(result[0].order).toBe(1);
+ expect(result[0].name).toBe('Only Item');
+ });
+
+ it('should not mutate the original array', () => {
+ const input = [
+ { name: 'A', order: 5 },
+ { name: 'B', order: 10 },
+ ];
+ const originalOrder0 = input[0].order;
+ const originalOrder1 = input[1].order;
+ ChecklistUtil.renumberChecklist(input);
+ // Original items should not be changed
+ expect(input[0].order).toBe(originalOrder0);
+ expect(input[1].order).toBe(originalOrder1);
+ });
+ });
+
+ describe('generateChecklistExport', () => {
+ it('should return an object with the correct type and version', () => {
+ const checklist = [
+ { id: 1, statement: 'Test item', description: 'Desc', answer: true, notes: [], assets: [] },
+ ];
+ const result = ChecklistUtil.generateChecklistExport(checklist);
+ expect(result.type).toBe(Constants.CHECKLIST_EXPORT_TYPE);
+ expect(result.version).toBe(Constants.CHECKLIST_EXPORT_VERSION);
+ });
+
+ it('should include an exportedAt timestamp in ISO format', () => {
+ const result = ChecklistUtil.generateChecklistExport([]);
+ expect(result.exportedAt).toBeDefined();
+ expect(new Date(result.exportedAt).toISOString()).toBe(result.exportedAt);
+ });
+
+ it('should export only name and description, not notes, assets, or scanResult', () => {
+ const checklist = [
+ {
+ id: 1,
+ statement: 'Check dependencies',
+ description: 'Verify all deps',
+ answer: true,
+ notes: [{ id: 'n1', content: 'Secret note' }],
+ assets: [{ uri: '/path/to/file.py', name: 'file.py' }],
+ scanResult: { Python: ['numpy'] },
+ },
+ ];
+ const result = ChecklistUtil.generateChecklistExport(checklist);
+ const exported = result.checklists[0];
+
+ // Should have ONLY name and description
+ expect(exported.name).toBe('Check dependencies');
+ expect(exported.description).toBe('Verify all deps');
+
+ // Should NOT have any internal data
+ expect(exported.notes).toBeUndefined();
+ expect(exported.assets).toBeUndefined();
+ expect(exported.scanResult).toBeUndefined();
+ expect(exported.answer).toBeUndefined();
+ expect(exported.id).toBeUndefined();
+ });
+
+ it('should handle items with no description', () => {
+ const checklist = [
+ { id: 1, statement: 'No description item', answer: false },
+ ];
+ const result = ChecklistUtil.generateChecklistExport(checklist);
+ expect(result.checklists[0].description).toBe('');
+ });
+
+ it('should export all items in the checklist', () => {
+ const checklist = [
+ { id: 1, statement: 'Item 1', description: '' },
+ { id: 2, statement: 'Item 2', description: 'Desc 2' },
+ { id: 3, statement: 'Item 3', description: 'Desc 3' },
+ ];
+ const result = ChecklistUtil.generateChecklistExport(checklist);
+ expect(result.checklists).toHaveLength(3);
+ });
+
+ it('should return an empty checklists array when given an empty checklist', () => {
+ const result = ChecklistUtil.generateChecklistExport([]);
+ expect(result.checklists).toEqual([]);
+ });
+ });
+
+ describe('validateAndParseImport', () => {
+ const makeValidExportString = (checklists) => {
+ return JSON.stringify({
+ type: Constants.CHECKLIST_EXPORT_TYPE,
+ version: Constants.CHECKLIST_EXPORT_VERSION,
+ exportedAt: new Date().toISOString(),
+ checklists,
+ });
+ };
+
+ const existingChecklist = [
+ { id: 1, statement: 'Software dependencies for the project are documented.', name: 'Dependency' },
+ { id: 2, statement: 'Data file(s) used in the project are documented.', name: 'Data' },
+ ];
+
+ describe('invalid JSON handling', () => {
+ it('should reject a string that is not valid JSON', () => {
+ const result = ChecklistUtil.validateAndParseImport('this is not json {{{', existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('not valid JSON');
+ expect(result.items).toEqual([]);
+ });
+
+ it('should reject a completely empty string', () => {
+ const result = ChecklistUtil.validateAndParseImport('', existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.items).toEqual([]);
+ });
+
+ it('should reject a string with broken JSON syntax', () => {
+ const broken = '{"type": "statwrap-checklist", "checklists": [{"name": "missing quote}]}';
+ const result = ChecklistUtil.validateAndParseImport(broken, existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('not valid JSON');
+ });
+ });
+
+ describe('format validation', () => {
+ it('should reject JSON that is missing the type field', () => {
+ const noType = JSON.stringify({ checklists: [{ name: 'Test' }] });
+ const result = ChecklistUtil.validateAndParseImport(noType, existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('type');
+ });
+
+ it('should reject JSON with a wrong type value', () => {
+ const wrongType = JSON.stringify({
+ type: 'package-json',
+ checklists: [{ name: 'Test' }],
+ });
+ const result = ChecklistUtil.validateAndParseImport(wrongType, existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('does not appear to be a StatWrap checklist');
+ });
+
+ it('should reject JSON where checklists is not an array', () => {
+ const notArray = JSON.stringify({
+ type: Constants.CHECKLIST_EXPORT_TYPE,
+ checklists: 'not an array',
+ });
+ const result = ChecklistUtil.validateAndParseImport(notArray, existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('valid "checklists" array');
+ });
+
+ it('should reject JSON where checklists array is empty', () => {
+ const empty = makeValidExportString([]);
+ const result = ChecklistUtil.validateAndParseImport(empty, existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('empty checklist');
+ });
+ });
+
+ describe('successful import', () => {
+ it('should accept a valid export with new items', () => {
+ const valid = makeValidExportString([
+ { name: 'New Custom Checklist', description: 'A new item' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeNull();
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].name).toBe('New Custom Checklist');
+ expect(result.items[0].description).toBe('A new item');
+ });
+
+ it('should accept multiple valid items', () => {
+ const valid = makeValidExportString([
+ { name: 'Item A', description: 'Desc A' },
+ { name: 'Item B', description: 'Desc B' },
+ { name: 'Item C', description: '' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(3);
+ });
+
+ it('should handle items with no description field', () => {
+ const valid = makeValidExportString([
+ { name: 'No Desc Item' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items[0].description).toBe('');
+ });
+ });
+
+ describe('sanitization', () => {
+ it('should truncate names that exceed the maximum length', () => {
+ const longName = 'X'.repeat(500);
+ const valid = makeValidExportString([
+ { name: longName, description: 'Short desc' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items[0].name).toHaveLength(Constants.CHECKLIST_NAME_MAX_LENGTH);
+ });
+
+ it('should truncate descriptions that exceed the maximum length', () => {
+ const longDesc = 'Y'.repeat(2000);
+ const valid = makeValidExportString([
+ { name: 'Valid Name', description: longDesc },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items[0].description).toHaveLength(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH);
+ });
+
+ it('should trim whitespace from names', () => {
+ const valid = makeValidExportString([
+ { name: ' Padded Name ', description: 'desc' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items[0].name).toBe('Padded Name');
+ });
+ });
+
+ describe('duplicate handling', () => {
+ it('should skip items that already exist in the current checklist (case-insensitive)', () => {
+ const valid = makeValidExportString([
+ { name: 'software dependencies for the project are documented.', description: '' },
+ { name: 'Brand New Item', description: '' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].name).toBe('Brand New Item');
+ expect(result.skippedCount).toBe(1);
+ });
+
+ it('should skip duplicate items within the import file itself', () => {
+ const valid = makeValidExportString([
+ { name: 'Duplicate Item', description: 'First occurrence' },
+ { name: 'Duplicate Item', description: 'Second occurrence' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].description).toBe('First occurrence');
+ expect(result.skippedCount).toBe(1);
+ });
+
+ it('should return valid=false when ALL items are duplicates', () => {
+ const valid = makeValidExportString([
+ { name: 'Software dependencies for the project are documented.' },
+ { name: 'Data file(s) used in the project are documented.' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(false);
+ expect(result.skippedCount).toBe(2);
+ });
+ });
+
+ describe('invalid item handling', () => {
+ it('should skip items where name is not a string', () => {
+ const valid = makeValidExportString([
+ { name: 123, description: 'Not a string name' },
+ { name: 'Valid Name', description: 'Valid' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+ expect(result.items[0].name).toBe('Valid Name');
+ expect(result.skippedCount).toBe(1);
+ });
+
+ it('should skip items where name is empty or whitespace-only', () => {
+ const valid = makeValidExportString([
+ { name: '', description: 'Empty name' },
+ { name: ' ', description: 'Whitespace name' },
+ { name: 'Actual Item', description: 'Valid' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+ expect(result.skippedCount).toBe(2);
+ });
+
+ it('should skip null items in the checklists array', () => {
+ const valid = makeValidExportString([
+ null,
+ { name: 'Valid After Null', description: '' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+ expect(result.skippedCount).toBe(1);
+ });
+ });
+
+ describe('security - field allowlisting', () => {
+ it('should only extract name and description, ignoring all extra fields', () => {
+ const valid = makeValidExportString([
+ {
+ name: 'Safe Item',
+ description: 'Safe description',
+ maliciousField: 'attack payload',
+ scanResult: { Python: ['hacked'] },
+ notes: [{ id: 'fake', content: 'injected note' }],
+ assets: [{ uri: '/etc/passwd' }],
+ answer: true,
+ },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+
+ const item = result.items[0];
+ expect(Object.keys(item)).toEqual(['name', 'description']);
+ expect(item.name).toBe('Safe Item');
+ expect(item.description).toBe('Safe description');
+
+ expect(item.maliciousField).toBeUndefined();
+ expect(item.scanResult).toBeUndefined();
+ expect(item.notes).toBeUndefined();
+ expect(item.assets).toBeUndefined();
+ expect(item.answer).toBeUndefined();
+ });
+
+ it('should preserve HTML/script tags as plain text in name (React will escape them)', () => {
+ const valid = makeValidExportString([
+ { name: '', description: '
![]()
' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist);
+ expect(result.valid).toBe(true);
+ expect(result.items[0].name).toBe('');
+ expect(result.items[0].description).toBe('
![]()
');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should work with an empty existing checklist', () => {
+ const valid = makeValidExportString([
+ { name: 'New Item', description: 'Desc' },
+ ]);
+ const result = ChecklistUtil.validateAndParseImport(valid, []);
+ expect(result.valid).toBe(true);
+ expect(result.items).toHaveLength(1);
+ });
+
+ it('should handle valid JSON that is not an object (e.g., a JSON array)', () => {
+ const jsonArray = JSON.stringify([{ name: 'Test' }]);
+ const result = ChecklistUtil.validateAndParseImport(jsonArray, existingChecklist);
+ expect(result.valid).toBe(false);
+ });
+
+ it('should handle valid JSON that is a primitive (e.g., a number)', () => {
+ const result = ChecklistUtil.validateAndParseImport('42', existingChecklist);
+ expect(result.valid).toBe(false);
+ });
+ });
+ });
+
+ describe('isDuplicateChecklist', () => {
+ const mockChecklist = [
+ { id: 1, uid: 'uid-1', statement: 'Dependency checks' },
+ { id: 2, uid: 'uid-2', statement: 'Data validation' }
+ ];
+
+ it('should return false if the input name or checklist is invalid', () => {
+ expect(ChecklistUtil.isDuplicateChecklist(null, mockChecklist)).toBe(false);
+ expect(ChecklistUtil.isDuplicateChecklist('Test', null)).toBe(false);
+ expect(ChecklistUtil.isDuplicateChecklist('Test', {})).toBe(false);
+ });
+
+ it('should return true if an exact duplicate exists', () => {
+ expect(ChecklistUtil.isDuplicateChecklist('Dependency checks', mockChecklist)).toBe(true);
+ });
+
+ it('should return true if a case-insensitive duplicate exists', () => {
+ expect(ChecklistUtil.isDuplicateChecklist('DEPENDENCY checks', mockChecklist)).toBe(true);
+ expect(ChecklistUtil.isDuplicateChecklist('data validation', mockChecklist)).toBe(true);
+ });
+
+ it('should return false if the name is entirely unique', () => {
+ expect(ChecklistUtil.isDuplicateChecklist('New custom check', mockChecklist)).toBe(false);
+ });
+
+ it('should ignore the duplicate check if the matching item matches the excludeId (edit mode)', () => {
+ expect(ChecklistUtil.isDuplicateChecklist('Data validation', mockChecklist, 'uid-2')).toBe(false);
+ });
+
+ it('should still flag a duplicate if edit mode name matches a DIFFERENT existing item', () => {
+ expect(ChecklistUtil.isDuplicateChecklist('Dependency checks', mockChecklist, 'uid-2')).toBe(true);
+ });
+ });
});
});
diff --git a/test/utils/templateContent.spec.js b/test/utils/templateContent.spec.js
new file mode 100644
index 00000000..31aa6602
--- /dev/null
+++ b/test/utils/templateContent.spec.js
@@ -0,0 +1,91 @@
+import { filterContentsByPaths, collectAllPaths } from '../../app/utils/templateContent';
+
+const sampleContents = [
+ {
+ type: 'directory',
+ name: 'Chapter 1',
+ path: '/chapter-1',
+ contents: [
+ { type: 'file', name: 'outline.md', path: '/chapter-1/outline.md' },
+ {
+ type: 'directory',
+ name: 'data',
+ path: '/chapter-1/data',
+ contents: [
+ { type: 'file', name: 'sample.csv', path: '/chapter-1/data/sample.csv' },
+ ],
+ },
+ ],
+ },
+ { type: 'file', name: 'README.md', path: '/README.md' },
+];
+
+describe('utils', () => {
+ describe('templateContent', () => {
+ describe('collectAllPaths', () => {
+ it('should return all paths recursively', () => {
+ const paths = collectAllPaths(sampleContents);
+ expect(paths).toEqual([
+ '/chapter-1',
+ '/chapter-1/outline.md',
+ '/chapter-1/data',
+ '/chapter-1/data/sample.csv',
+ '/README.md',
+ ]);
+ });
+
+ it('should return an empty array for null input', () => {
+ expect(collectAllPaths(null)).toEqual([]);
+ });
+
+ it('should return an empty array for undefined input', () => {
+ expect(collectAllPaths(undefined)).toEqual([]);
+ });
+
+ it('should return an empty array for empty contents', () => {
+ expect(collectAllPaths([])).toEqual([]);
+ });
+ });
+
+ describe('filterContentsByPaths', () => {
+ it('should return all items when all paths are checked', () => {
+ const allPaths = collectAllPaths(sampleContents);
+ const result = filterContentsByPaths(sampleContents, allPaths);
+ expect(result).toEqual(sampleContents);
+ });
+
+ it('should return an empty array when no paths are checked', () => {
+ const result = filterContentsByPaths(sampleContents, []);
+ expect(result).toEqual([]);
+ });
+
+ it('should return an empty array for null contents', () => {
+ expect(filterContentsByPaths(null, ['/foo'])).toEqual([]);
+ });
+
+ it('should keep only selected files', () => {
+ const result = filterContentsByPaths(sampleContents, ['/README.md']);
+ expect(result.length).toBe(1);
+ expect(result[0].name).toBe('README.md');
+ });
+
+ it('should keep a folder and filter its children', () => {
+ const result = filterContentsByPaths(sampleContents, [
+ '/chapter-1',
+ '/chapter-1/data',
+ '/chapter-1/data/sample.csv',
+ ]);
+ expect(result.length).toBe(1);
+ expect(result[0].name).toBe('Chapter 1');
+ // outline.md was not in checkedPaths, so it should be excluded
+ expect(result[0].contents.length).toBe(1);
+ expect(result[0].contents[0].name).toBe('data');
+ });
+
+ it('should exclude a folder if it is not in checkedPaths', () => {
+ const result = filterContentsByPaths(sampleContents, ['/README.md']);
+ expect(result.find((i) => i.name === 'Chapter 1')).toBeUndefined();
+ });
+ });
+ });
+});
\ No newline at end of file