diff --git a/assets/css/mdk-download.css b/assets/css/mdk-download.css index 648ca94..19a7751 100644 --- a/assets/css/mdk-download.css +++ b/assets/css/mdk-download.css @@ -44,8 +44,10 @@ body#download #preflight-checklist{ margin: 0 auto; } -body#download #preflight-checklist > span .action{ - font-size: 10px; +body#download #preflight-checklist > span.action{ + display: block; + margin-left: 20px; + font-size: 12px; } body#download #preflight-checklist > span:before{ @@ -54,15 +56,15 @@ body#download #preflight-checklist > span:before{ width: 20px; } -body#download #preflight-checklist > span.pass:before{ +body#download #preflight-checklist > span:not(.action).pass:before{ content: "✔"; color: green; } -body#download #preflight-checklist > span.fail:before{ +body#download #preflight-checklist > span:not(.action).fail:before{ content: "X"; color: red; } -body#download #preflight-checklist > span.unknown:before{ +body#download #preflight-checklist > span:not(.action).unknown:before{ content: "?"; color: orange; -} \ No newline at end of file +} diff --git a/express.js b/express.js index db0e2a6..44feec8 100644 --- a/express.js +++ b/express.js @@ -254,6 +254,30 @@ var getQuestion = (ids) => { return qlist; }; +const INSTALL_TYPE_NUMBER = 'number' +const INSTALL_TYPE_BOOLEAN = 'boolean' +const INSTALL_TYPE_STRING = 'string' +const INSTALL_TYPE_ARRAY = 'object' + +const verifyInstallProp = (prop, desiredType) => { + const propType = typeof prop + if(propType === 'undefined' || propType === 'null') return false + if(desiredType === INSTALL_TYPE_BOOLEAN) { + //yaml parser interprets all valid YAML boolean values as strings + if(propType !== 'string') return false + //if we want a boolean, make sure the string we got is one of the accepted YAML boolean strings + const match = prop.match(/^(y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF){1}$/) + if(!match) return false + } + if(desiredType === INSTALL_TYPE_STRING && propType !== 'string') return false + if(desiredType === INSTALL_TYPE_NUMBER && propType !== 'number') return false + if(desiredType === INSTALL_TYPE_ARRAY) { + if(propType !== 'object') return false + if(prop.length < 1) return false + } + return true +} + // app is passed a reference to the webpack dev server (Express.js) module.exports = (app) => { @@ -396,9 +420,10 @@ module.exports = (app) => { playerCallback: '', scoreScreenCallback: '' } - //let's see if we can get the demo json file + let allGood = true + //check demo.json try { - let demo = JSON.parse(getFileFromWebpack('demo.json').toString()) + const demo = JSON.parse(getFileFromWebpack('demo.json').toString()) if(!demo.name) { status.demo = 'fail' @@ -412,11 +437,268 @@ module.exports = (app) => { } else if(!demo.qset.data) { status.demo = 'fail' action.demo = "'qset' 'data' property missing" + } else { + status.demo = 'pass' } - status.demo = 'pass' } catch(error) { + //TODO: use the error object to determine why there was a failure + // maybe move the failure contextualization from the try to the catch status.demo = 'fail' - action.demo = 'demo.json missing or can\'t be parsed' + action.demo = "demo.json missing or can't be parsed" + } + + //check install.yaml + //scope this so we can use it for other checks later + let install = null + try { + install = yaml.parse(getInstall().toString()) + if(!install.general) { + status.install = 'fail' + action.install = "'general' property missing" + } else if(!verifyInstallProp(install.general.name, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'general' 'name' property missing or not a string" + } else if(!verifyInstallProp(install.general.group, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'general' 'group' property missing or not a string" + } else if(!verifyInstallProp(install.general.height, INSTALL_TYPE_NUMBER)) { + status.install = 'fail' + action.install = "'general' 'height' property missing or not a number" + } else if(!verifyInstallProp(install.general.width, INSTALL_TYPE_NUMBER)) { + status.install = 'fail' + action.install = "'general' 'width' property missing or not a number" + } else if(!verifyInstallProp(install.general.in_catalog, INSTALL_TYPE_BOOLEAN)) { + status.install = 'fail' + action.install = "'general' 'in_catalog' property missing or not a boolean" + } else if(!verifyInstallProp(install.general.is_editable, INSTALL_TYPE_BOOLEAN)) { + status.install = 'fail' + action.install = "'general' 'is_editable' property missing or not a boolean" + } else if(!verifyInstallProp(install.general.is_playable, INSTALL_TYPE_BOOLEAN)) { + status.install = 'fail' + action.install = "'general' 'is_playable' property missing or not a boolean" + } else if(!verifyInstallProp(install.general.is_qset_encrypted, INSTALL_TYPE_BOOLEAN)) { + status.install = 'fail' + action.install = "'general' 'is_qset_encrypted' property missing or not a boolean" + } else if(!verifyInstallProp(install.general.api_version, INSTALL_TYPE_NUMBER)) { + status.install = 'fail' + action.install = "'general' 'api_version' property missing or not a number" + } else if(!install.files) { + status.install = 'fail' + action.install = "'files' property missing" + } else if(!verifyInstallProp(install.files.creator, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'files' 'creator' property missing or not a string" + } else if(!verifyInstallProp(install.files.player, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'files' 'player' property missing or not a string" + } else if(!verifyInstallProp(install.files.flash_version, INSTALL_TYPE_NUMBER)) { + status.install = 'fail' + action.install = "'files' 'flash_version' property missing or not a number" + } else if(!install.score) { + status.install = 'fail' + action.install = "'score' property missing" + } else if(!verifyInstallProp(install.score.is_scorable, INSTALL_TYPE_BOOLEAN)) { + status.install = 'fail' + action.install = "'score' 'is_scorable' property missing or not a boolean" + } else if(!verifyInstallProp(install.score.score_module, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'score' 'score_module' property missing or not a string" + } else if(install.score.score_screen && !verifyInstallProp(install.score.score_screen, INSTALL_TYPE_STRING)) { + //custom score screens are optional + status.install = 'fail' + action.install = "'score' 'score_screen' property not a string" + } else if(!install.meta_data) { + status.install = 'fail' + action.install = "'meta_data' property missing" + } else if(!verifyInstallProp(install.meta_data.features, INSTALL_TYPE_ARRAY)) { + status.install = 'fail' + action.install = "'meta_data' 'features' property missing, not an array, or empty" + } else if(!verifyInstallProp(install.meta_data.supported_data, INSTALL_TYPE_ARRAY)) { + status.install = 'fail' + action.install = "'meta_data' 'supported_data' property missing, not an array, or empty" + } else if(!verifyInstallProp(install.meta_data.about, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'meta_data' 'about' property missing or not a string" + } else if(!verifyInstallProp(install.meta_data.excerpt, INSTALL_TYPE_STRING)) { + status.install = 'fail' + action.install = "'meta_data' 'excerpt' property missing or not a string" + } else { + status.install = 'pass' + } + } catch(error) { + status.install = 'fail' + action.install = "install.yaml missing or can't be parsed" + } + + status.screenshot = 'pass' + //check screenshots + for(let i = 1; i <= 3; i++) { + try { + getFileFromWebpack(path.join('img','screen-shots',`${i}.png`)) + } catch(error) { + status.screenshot = 'fail' + action.screenshot = `file 'src/_screen-shots/${i}.png' missing` + } + try { + getFileFromWebpack(path.join('img','screen-shots',`${i}-thumb.png`)) + } catch(error) { + status.screenshot = 'fail' + action.screenshot = `file 'src/_screen-shots/${i}-thumb.png' missing` + } + } + + //check icons + const iconSizes = [60,92,275,394] + status.icon = 'pass' + iconSizes.forEach(size => { + try { + getFileFromWebpack(path.join('img',`icon-${size}.png`)) + } catch(error) { + status.icon = 'fail' + action.icon = `file 'src/_icons/icon-${size}.png' missing` + } + try { + getFileFromWebpack(path.join('img',`icon-${size}@2x.png`)) + } catch(error) { + status.icon = 'fail' + action.icon = `file 'src/_icons/icon-${size}@2x.png' missing` + } + }) + + //check score module + if(install && install.score.score_module) { + try { + //running regular expressions on a string representation of the score module should be good enough + const scoreModule = getFileFromWebpack(path.join('_score-modules', 'score_module.php')).toString() + + const phpOpenMatch = scoreModule.match(/^<\?php$/gm) + const namespaceMatch = scoreModule.match(/^namespace Materia;$/gm) + //get the name of the score module this widget uses from install.yaml + const classCheck = new RegExp(`^class Score_Modules_${install.score.score_module} extends Score_Module$`, 'gm') + const classMatch = scoreModule.match(classCheck) + const functionMatch = scoreModule.match(/^\t{1}public function check_answer\(\$(\w)+\)$/gm) + if(!phpOpenMatch || phpOpenMatch.length > 1) { + status.scoreModule = 'fail' + action.scoreModule = "' 1) { + status.scoreModule = 'fail' + action.scoreModule = "'namespace Materia;' missing or used more than once" + } else if(!classMatch || classMatch.length > 1) { + status.scoreModule = 'fail' + action.scoreModule = `score module class 'Score_Modules_${install.score.score_module}' was not defined or defined more than once` + } else if(!functionMatch || functionMatch > 1) { + status.scoreModule = 'fail' + action.scoreModule = "'check_answer' function was not defined or defined more than once" + } else { + status.scoreModule = 'pass' + } + + } catch(error) { + status.scoreModule = 'fail' + action.scoreModule = "score module missing or can't be parsed" + } + } else { + //if we can't get the name of the score module we need, we can't check validity + //this shouldn't ever happen if the whole install.yaml check block passes + action.scoreModule = "can't verify score module name from install.yaml" + } + + //check creator callbacks + if(install.files.creator != 'default') { + try { + const creator = getFileFromWebpack('creator.js').toString() + let missingCreatorCalls = [] + const neededCreatorCallbacks = [ + 'initNewWidget', + 'initExistingWidget', + 'onMediaImportComplete', + 'onQuestionImportComplete', + 'onSaveClicked', + 'onSaveComplete' + ] + neededCreatorCallbacks.forEach(callback => { + const callbackCheck = new RegExp(`(function ${callback}){1}|(${callback} = function){1}`, 'g') + const callbackMatch = creator.match(callbackCheck) + if(!callbackMatch || callbackMatch.length > 1) { + status.creatorCallback = 'fail' + action.creatorCallback = `'${callback}' method missing or defined more than once` + missingCreatorCalls.push(callback) + } + }) + const neededCreatorCoreCalls = [ + 'save', + // 'cancelSave', + 'start' + ] + neededCreatorCoreCalls.forEach(coreCall => { + const coreCallCheck = new RegExp(`Materia.CreatorCore.${coreCall}`, 'g') + const coreCallMatch = creator.match(coreCallCheck) + if(!coreCallMatch || coreCallMatch.length > 1) { + status.creatorCallback = 'fail' + action.creatorCallback = `CreatorCore '${coreCall}' method never called` + missingCreatorCalls.push(coreCall) + } + }) + if(missingCreatorCalls.length == 0) status.creatorCallback = 'pass' + } catch(error) { + status.creatorCallback = 'fail' + action.creatorCallback = "creator source code missing or can't be parsed" + } + } else { + status.creatorCallback = 'pass' + action.creatorCallback = 'widget using default creator' + } + + //check player callbacks + try { + const player = getFileFromWebpack('player.js').toString() + const playerSaveMatch = player.match(/Materia.Engine.start/g) + if(!playerSaveMatch || playerSaveMatch > 1) { + status.playerCallback = 'fail' + action.playerCallback = "EngineCore 'start' method missing or called more than once" + } else { + status.playerCallback = 'pass' + } + } catch(error) { + status.playerCallback = 'fail' + action.playerCallback = "player source code missing or can't be parsed" + } + + //check score screen callbacks + if(install.score.score_screen) { + const scoreScreen = getFileFromWebpack('scorescreen.js').toString() + let missingScoreScreenCalls = [] + const neededScoreScreenCallbacks = [ + 'start', + 'update' + ] + neededScoreScreenCallbacks.forEach(callback => { + const callbackCheck = new RegExp(`(function ${callback}){1}|(${callback} = function){1}`, 'g') + const callbackMatch = scoreScreen.match(callbackCheck) + if(!callbackMatch || callbackMatch.length > 1) { + status.scoreScreenCallback = 'fail' + action.scoreScreenCallback = `'${callback}' method missing or defined more than once` + missingScoreScreenCalls.push(callback) + } + }) + const neededScoreCoreCalls = [ + // 'hideScoresOverview', + // 'hideResultsTable', + 'start' + ] + neededScoreCoreCalls.forEach(coreCall => { + const coreCallCheck = new RegExp(`Materia.ScoreCore.${coreCall}`, 'g') + const coreCallMatch = scoreScreen.match(coreCallCheck) + if(!coreCallMatch || coreCallMatch.length > 1) { + status.scoreScreenCallback = 'fail' + action.scoreScreenCallback = `ScoreCore '${coreCall}' method never called` + missingScoreScreenCalls.push(coreCall) + } + }) + if(missingScoreScreenCalls.length == 0) status.scoreScreenCallback = 'pass' + } else { + status.scoreScreenCallback = 'pass' + action.scoreScreenCallback = 'widget not using custom score screen' } const checklist = [ @@ -462,7 +744,12 @@ module.exports = (app) => { }, ] - res.locals = Object.assign(res.locals, {template: 'download', checklist: checklist}) + //do one more pass over the whole checklist - if there are any failures, prevent build/install + checklist.forEach(item => { + if(item.status == 'fail') allGood = false + }) + + res.locals = Object.assign(res.locals, {template: 'download', checklist: checklist, allGood: allGood}) res.render(res.locals.template) }) diff --git a/views/download.html b/views/download.html index 1404b52..6f568e7 100644 --- a/views/download.html +++ b/views/download.html @@ -11,19 +11,21 @@

Preflight Checks:

{{text}} - - {{action}} - + + + {{action}} {{/checklist}}
+{{#allGood}}
+{{/allGood}}
Cancel