├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS.md └── workflows │ ├── build.yml │ └── commitlint.yml ├── .gitignore ├── .husky ├── .gitattributes └── commit-msg ├── .nvmrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── TRADEMARK ├── commitlint.config.js ├── docker-compose.yml ├── lib ├── index.js ├── sb1.js ├── sb2.js ├── sb3.js └── utility.js ├── package-lock.json ├── package.json ├── release.config.js └── test ├── fixtures ├── invalid │ └── garbage.jpg ├── sb1 │ └── AllBlocks-Scratch1.4.sb ├── sb2 │ ├── cloud.sb2 │ ├── cloud_complex.sb2 │ ├── cloud_opcodes.sb2 │ ├── complex.sb2 │ ├── default.json │ ├── default.sb2 │ ├── infoMissing.json │ └── invalid-costumes.json └── sb3 │ ├── badExtensions.json │ ├── cloud.sb3 │ ├── cloud_complex.sb3 │ ├── cloud_opcodes.sb3 │ ├── complex.sb3 │ ├── default.json │ ├── default.sb3 │ ├── extensions.sb3 │ ├── extensionsObject.json │ ├── missingVariableField.json │ └── primitiveVariableAndListBlocks.json └── unit ├── cloud.js ├── cloud_opcodes.js ├── error.js ├── sb1.js ├── sb2.js ├── sb3.js ├── spec.js └── utility.js /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.nyc_output 3 | /coverage 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "requireConfigFile": false 5 | }, 6 | "extends": ["scratch", "scratch/node"] 7 | } 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS.md: -------------------------------------------------------------------------------- 1 | @scratchfoundation/scratch-engineering 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build-scratch-analysis 2 | on: 3 | push: 4 | jobs: 5 | setup: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Setup Node.js 10 | uses: actions/setup-node@v3 11 | with: 12 | node-version-file: '.nvmrc' 13 | - name: Install Dependencies 14 | run: npm ci 15 | - name: Run Tests 16 | run: npm test 17 | - name: Semantic Release 18 | env: 19 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | run: npx --no -- semantic-release 22 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint commit messages 2 | on: [pull_request] 3 | 4 | jobs: 5 | commitlint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 9 | - uses: wagoid/commitlint-github-action@5ce82f5d814d4010519d15f0552aec4f17a1e1fe # v5 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## OSX 2 | .DS_Store 3 | 4 | ## NPM 5 | /node_modules 6 | npm-* 7 | 8 | ## Code Coverage 9 | .nyc_output/ 10 | coverage/ 11 | -------------------------------------------------------------------------------- /.husky/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See 4 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [2.4.1](https://github.com/scratchfoundation/scratch-analysis/compare/v2.4.0...v2.4.1) (2025-03-18) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * extraction of extensions ([344cf87](https://github.com/scratchfoundation/scratch-analysis/commit/344cf878dcfe291489b8c459213ca111165a20f0)) 12 | 13 | # [2.4.0](https://github.com/scratchfoundation/scratch-analysis/compare/v2.3.0...v2.4.0) (2025-02-06) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * fix error when checking for cloud variables ([ec517f1](https://github.com/scratchfoundation/scratch-analysis/commit/ec517f1199d1e865aacb23e1ad4475f30ea361ed)) 19 | 20 | 21 | ### Features 22 | 23 | * support SB1 project files ([c20fb0b](https://github.com/scratchfoundation/scratch-analysis/commit/c20fb0b5804509687b0e9cc006d75173021a39dc)) 24 | 25 | # [2.3.0](https://github.com/scratchfoundation/scratch-analysis/compare/v2.2.1...v2.3.0) (2025-01-28) 26 | 27 | 28 | ### Features 29 | 30 | * fix version number ([9715b00](https://github.com/scratchfoundation/scratch-analysis/commit/9715b001ed52dc9651377417e3439f6bccc82700)) 31 | 32 | ## [2.2.1](https://github.com/scratchfoundation/scratch-analysis/compare/v2.2.0...v2.2.1) (2024-11-07) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * fix version number some more ([b0ea4d9](https://github.com/scratchfoundation/scratch-analysis/commit/b0ea4d947544d836ab075cd17ba12d4a001a4fd2)) 38 | 39 | # [2.2.0](https://github.com/scratchfoundation/scratch-analysis/compare/v2.1.1...v2.2.0) (2024-11-07) 40 | 41 | 42 | ### Features 43 | 44 | * fix version number ([428e191](https://github.com/scratchfoundation/scratch-analysis/commit/428e191a7f24760552ee40170353a9f7b599154a)) 45 | 46 | ## [2.1.1](https://github.com/scratchfoundation/scratch-analysis/compare/v2.1.0...v2.1.1) (2024-11-07) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * **release:** add semantic-release config ([3a84ed3](https://github.com/scratchfoundation/scratch-analysis/commit/3a84ed33540a29348a5bc0d50ef84cc34ba6c46d)) 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | RUN mkdir -p /var/app/current 4 | WORKDIR /var/app/current 5 | COPY . ./ 6 | RUN rm -rf ./node_modules 7 | RUN npm install 8 | RUN npm install -g nodemon tap 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Code-to-Learn Foundation d/b/a Scratch Foundation 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## scratch-analysis 2 | #### Analysis tool for summarizing the structure, composition, and complexity of [Scratch](https://scratch.mit.edu) programs. 3 | 4 | [![Build Status](https://travis-ci.org/LLK/scratch-analysis.svg?branch=develop)](https://travis-ci.org/LLK/scratch-analysis) 5 | [![Greenkeeper badge](https://badges.greenkeeper.io/LLK/scratch-analysis.svg)](https://greenkeeper.io/) 6 | 7 | ## Getting Started 8 | ```bash 9 | npm install scratch-analysis 10 | ``` 11 | 12 | ```js 13 | const analysis = require('scratch-analysis'); 14 | analysis(buffer, function (err, result) { 15 | // handle any validation errors and ... 16 | // do something interesting with the results! 17 | }); 18 | ``` 19 | 20 | ## Analysis Modules 21 | ### General 22 | The `scratch-analysis` module will return an object containing high-level summary information about the project: 23 | 24 | | Key | Attributes | 25 | | ----------------- | -------------------------------------------------------- | 26 | | `scripts` | `count` | 27 | | `blocks` | `count`, `unique`, `list`, `frequency` | 28 | | `sprites` | `count` | 29 | | `variables` | `count`, `id` | 30 | | `cloud` | `count`, `id` | 31 | | `lists` | `count` | 32 | | `costumes` | `count`, `list`, `hash` | 33 | | `sounds` | `count`, `list`, `hash` | 34 | | `extensions` | `count`, `list` | 35 | | `comments` | `count` | 36 | 37 | ### Concepts 38 | **Coming Soon** 39 | 40 | ### Complexity 41 | **Coming Soon** 42 | 43 | ### Classification 44 | **Coming Soon** 45 | 46 | ## References 47 | ### New Frameworks for Studying and Assessing the Development of Computational Thinking 48 | Author(s): Karen Brennan, Mitchel Resnick 49 | PDF: [Download](https://web.media.mit.edu/~kbrennan/files/Brennan_Resnick_AERA2012_CT.pdf) 50 | -------------------------------------------------------------------------------- /TRADEMARK: -------------------------------------------------------------------------------- 1 | The Scratch trademarks, including the Scratch name, logo, the Scratch Cat, Gobo, Pico, Nano, Tera and Giga graphics (the "Marks"), are property of the Massachusetts Institute of Technology (MIT). Marks may not be used to endorse or promote products derived from this software without specific prior written permission. 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | ignores: [message => message.startsWith('chore(release):')] 4 | }; 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | volumes: 3 | npm_data: 4 | runtime_data: 5 | 6 | networks: 7 | default: 8 | external: 9 | name: scratchapi_scratch_network 10 | 11 | services: 12 | app: 13 | container_name: scratch-analysis-lib 14 | hostname: scratch-analysis 15 | build: 16 | context: ./ 17 | dockerfile: Dockerfile 18 | image: scratch-analysis:latest 19 | command: node -e "require('http').createServer((req, res) => { res.end('OK'); }).listen(8080, () => {console.log('Listening on 8080'); } );" 20 | volumes: 21 | - type: bind 22 | source: ./ 23 | target: /var/app/current 24 | consistency: cached 25 | volume: 26 | nocopy: true 27 | - npm_data:/var/app/current/node_modules 28 | - runtime_data:/runtime 29 | ports: 30 | - "9999:8080" 31 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const parser = require('scratch-parser'); 2 | 3 | const {SB1Analyzer} = require('./sb1'); 4 | const sb2 = require('./sb2'); 5 | const sb3 = require('./sb3'); 6 | 7 | module.exports = function (buffer, callback) { 8 | parser(buffer, false, (err, result) => { 9 | if (err === 'Parser only supports Scratch 2.X and above') { 10 | return new SB1Analyzer().analyze(buffer, callback); 11 | } else if (err) { 12 | return callback(err); 13 | } 14 | 15 | // Extract the project object from the parser results 16 | const project = result[0]; 17 | // Check if the input buffer was a zip file 18 | const zip = result[1]; 19 | project.isBundle = typeof zip !== 'undefined' && zip !== null; 20 | 21 | // Push project object to the appropriate analysis handler 22 | switch (project.projectVersion) { 23 | case 2: 24 | sb2(project, callback); 25 | break; 26 | case 3: 27 | sb3(project, callback); 28 | break; 29 | } 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /lib/sb1.js: -------------------------------------------------------------------------------- 1 | const scratchParserValidate = require('scratch-parser/lib/validate'); 2 | const {SB1File} = require('scratch-sb1-converter'); 3 | 4 | const sb2 = require('./sb2'); 5 | 6 | /** 7 | * SB1Analyzer class for analyzing SB1 projects. 8 | */ 9 | class SB1Analyzer { 10 | /** 11 | * Creates a new SB1Analyzer instance. 12 | * @param {Function} validate - The validate function to use. 13 | */ 14 | constructor (validate = scratchParserValidate) { 15 | this.validate = validate; 16 | } 17 | 18 | /** 19 | * Converts an SB1 project buffer to an SB2 project object, 20 | * validates it using the provided validate function, 21 | * analyzes it, and returns summary information about the project. 22 | * @param {Buffer} buffer Project buffer (SB1 format) 23 | * @param {Function} callback Callback function 24 | * @return {void} 25 | */ 26 | analyze (buffer, callback) { 27 | try { 28 | const sb1File = new SB1File(buffer); 29 | const project = sb1File.json; 30 | 31 | this.validate(false, project, (err, validatedProject) => { 32 | if (err) { 33 | return callback(err); 34 | } 35 | 36 | validatedProject.isBundle = true; 37 | validatedProject.projectVersion = 1; 38 | return sb2(validatedProject, callback); 39 | }); 40 | } catch (err) { 41 | return callback(err); 42 | } 43 | } 44 | } 45 | 46 | module.exports = {SB1Analyzer}; 47 | -------------------------------------------------------------------------------- /lib/sb2.js: -------------------------------------------------------------------------------- 1 | const utility = require('./utility'); 2 | 3 | /** 4 | * Returns an array of items matching the specified attribute. 5 | * @param {object} project Project object (SB2 format) 6 | * @param {string} attribute Attribute to extract and flatten 7 | * @return {array} Array of specified attribute 8 | */ 9 | const flatten = function (project, attribute) { 10 | // Storage object 11 | let result = []; 12 | 13 | // If attribute exists at the top level of the project, append it 14 | if (typeof project[attribute] !== 'undefined') { 15 | result = project[attribute]; 16 | } 17 | 18 | // Iterate over child elements and append to result array 19 | for (let i in project.children) { 20 | const child = project.children[i]; 21 | if (typeof child[attribute] !== 'undefined') { 22 | result = result.concat(child[attribute]); 23 | } 24 | } 25 | 26 | return result; 27 | }; 28 | 29 | /** 30 | * Extract summary information from a specific project attribute. Will attempt 31 | * to concatinate all children when generating summary. 32 | * @param {object} project Project object (SB2 format) 33 | * @param {string} attribute Attribute key 34 | * @param {string} id "id" key 35 | * @param {string} hash "hash" key 36 | * @return {object} Summary information 37 | */ 38 | const extract = function (project, attribute, id, hash) { 39 | // Create storage objects and flatten project 40 | let idList = null; 41 | let hashList = null; 42 | let elements = flatten(project, attribute); 43 | 44 | // Extract ids if specified 45 | if (typeof id !== 'undefined') { 46 | idList = []; 47 | for (var x in elements) { 48 | idList.push(elements[x][id]); 49 | } 50 | } 51 | 52 | // Extract hashes if specified 53 | if (typeof hash !== 'undefined') { 54 | hashList = []; 55 | for (var y in elements) { 56 | hashList.push(elements[y][hash]); 57 | } 58 | } 59 | 60 | // Build result and return 61 | var result = { 62 | count: elements.length 63 | }; 64 | if (idList !== null) result.id = idList; 65 | if (hashList !== null) result.hash = hashList; 66 | 67 | return result; 68 | }; 69 | 70 | /** 71 | * Extract summary information about backdrops including 72 | * count, list of backdrop names and list of backdrop hashes. 73 | * Backdrops are a subset of all costumes. 74 | * Backdrops are a costumes from the stage object. 75 | * @param {object} project Project object (SB2 format) 76 | * @return {object} Summary information 77 | */ 78 | const backdrops = function (project) { 79 | let stageCostumes = project.costumes; 80 | 81 | if (!Array.isArray(stageCostumes)) { 82 | return {count: 0, id: [], hash: []}; 83 | } 84 | 85 | return { 86 | count: stageCostumes.length, 87 | id: stageCostumes.map((sc) => sc.costumeName), 88 | hash: stageCostumes.map((sc) => sc.baseLayerMD5) 89 | }; 90 | }; 91 | 92 | /** 93 | * Extract number of sprites from a project object. Will attempt to ignore 94 | * "children" which are not sprites. 95 | * @param {object} input Project object (SB2 format) 96 | * @return {object} Sprite information 97 | */ 98 | const sprites = function (input) { 99 | let result = 0; 100 | 101 | for (let i in input.children) { 102 | if (Object.prototype.hasOwnProperty.call(input.children[i], 'spriteInfo')) result++; 103 | } 104 | 105 | return {count: result}; 106 | }; 107 | 108 | /** 109 | * Extracts all blocks and generates a frequency count. 110 | * @param {object} project Project object (SB2 format) 111 | * @return {object} Block information 112 | */ 113 | const blocks = function (project) { 114 | // Storage objects 115 | const result = []; 116 | 117 | /** 118 | * Determine if a argument is the name of a known cloud variable. 119 | * @param {string} arg Argument (variable name) 120 | * @return {boolean} Is cloud variable? 121 | */ 122 | const isArgCloudVar = function (arg) { 123 | // Validate argument 124 | // @note "Hacked" inputs here could be objects (arrays) 125 | if (typeof arg !== 'string') return false; 126 | 127 | // Iterate over global variables and check to see if arg matches 128 | for (let i in project.variables) { 129 | const variable = project.variables[i]; 130 | if (variable.name === arg && variable.isPersistent) return true; 131 | } 132 | return false; 133 | }; 134 | 135 | /** 136 | * Walk scripts array(s) and build block list. 137 | * @param {array} stack Stack of blocks 138 | * @return {void} 139 | */ 140 | const walk = function (stack) { 141 | for (let i in stack) { 142 | // Skip if item is not array 143 | if (!Array.isArray(stack[i])) continue; 144 | 145 | // Recurse if first item is not a string 146 | if (typeof stack[i][0] !== 'string') { 147 | walk(stack[i]); 148 | continue; 149 | } 150 | 151 | // Get opcode and check variable manipulation for the presence of 152 | // cloud variables 153 | let opcode = stack[i][0]; 154 | if (opcode === 'setVar:to:' || opcode === 'changeVar:by:') { 155 | if (isArgCloudVar(stack[i][1])) { 156 | opcode += 'cloud:'; 157 | } 158 | } 159 | 160 | // Add to block list 161 | result.push(opcode); 162 | 163 | // Don't pull in params from procedures 164 | if (opcode === 'procDef') continue; 165 | 166 | // Move to next item and walk 167 | walk(stack[i].slice(1)); 168 | } 169 | }; 170 | walk(flatten(project, 'scripts')); 171 | 172 | // Generate frequency count 173 | const freq = utility.frequency(result); 174 | 175 | // Build result and return 176 | return { 177 | count: result.length, 178 | unique: Object.keys(freq).length, 179 | id: result, 180 | frequency: freq 181 | }; 182 | }; 183 | 184 | /** 185 | * Extracts extension information. 186 | * @param {object} project Project object (SB2 format) 187 | * @return {object} Extension information 188 | */ 189 | const extensions = function (project) { 190 | const result = {count: 0, id: []}; 191 | const ext = project.info?.savedExtensions; 192 | 193 | // Check to ensure project includes any extensions 194 | if (typeof ext === 'undefined') return result; 195 | 196 | // Iterate over extensions and build list 197 | for (let i in ext) { 198 | result.id.push(ext[i].extensionName); 199 | } 200 | 201 | // Count and return 202 | result.count = result.id.length; 203 | return result; 204 | }; 205 | 206 | /** 207 | * Extracts cloud variable information. 208 | * @param {object} project Project object (SB2 format) 209 | * @param {array} names Names of all variables in project 210 | * @return {object} Cloud variable information 211 | */ 212 | const cloud = function (project, names) { 213 | const obj = []; 214 | 215 | // Extract "isPersistent" parameter from all variables in project 216 | const cloudyness = extract(project, 'variables', 'isPersistent').id; 217 | 218 | // Ensure that variable names and isPersistent parameter list are the same 219 | // length 220 | if (names.length !== cloudyness.length) return -1; 221 | 222 | // Iterate over isPersistent values, and extract names of any that are true 223 | for (let i in cloudyness) { 224 | if (cloudyness[i]) { 225 | obj.push(names[i]); 226 | } 227 | } 228 | 229 | return { 230 | count: obj.length, 231 | id: obj 232 | }; 233 | }; 234 | 235 | /** 236 | * Analyzes a project and returns summary information about the project. 237 | * @param {object} project Project object (SB2 format) 238 | * @param {Function} callback Callback function 239 | * @return {void} 240 | */ 241 | module.exports = function (project, callback) { 242 | // Create metadata object 243 | const meta = { 244 | scripts: extract(project, 'scripts'), 245 | variables: extract(project, 'variables', 'name'), 246 | lists: extract(project, 'lists', 'listName'), 247 | comments: extract(project, 'scriptComments'), 248 | sounds: extract(project, 'sounds', 'soundName', 'md5'), 249 | costumes: extract(project, 'costumes', 'costumeName', 'baseLayerMD5'), 250 | projectVersion: project.projectVersion, 251 | isBundle: project.isBundle 252 | }; 253 | 254 | meta.backdrops = backdrops(project); 255 | 256 | meta.cloud = cloud(project, meta.variables.id); 257 | 258 | // Sprites 259 | meta.sprites = sprites(project); 260 | 261 | // Blocks 262 | meta.blocks = blocks(project); 263 | 264 | // Extensions 265 | meta.extensions = extensions(project); 266 | 267 | // Metadata is only in sb3s so just fill in an empty object. 268 | meta.meta = {}; 269 | 270 | // Return all metadata 271 | return callback(null, meta); 272 | }; 273 | -------------------------------------------------------------------------------- /lib/sb3.js: -------------------------------------------------------------------------------- 1 | const utility = require('./utility'); 2 | 3 | const scripts = function (targets) { 4 | // Storage objects 5 | let occurrences = 0; 6 | 7 | // Iterate over all blocks in each target, and look for "top level" blocks 8 | for (let t in targets) { 9 | for (let b in targets[t].blocks) { 10 | if (targets[t].blocks[b].topLevel) occurrences++; 11 | } 12 | } 13 | 14 | return { 15 | count: occurrences 16 | }; 17 | }; 18 | 19 | const costumes = function (targets) { 20 | // Storage objects 21 | let occurrences = 0; 22 | let nameList = []; 23 | let hashList = []; 24 | 25 | for (let t in targets) { 26 | for (let a in targets[t].costumes) { 27 | const costume = targets[t].costumes[a]; 28 | occurrences++; 29 | nameList.push(costume.name); 30 | 31 | let hash = costume.md5ext || `${costume.assetId}.${costume.dataFormat}`; 32 | hashList.push(hash); 33 | } 34 | } 35 | 36 | // field are named this way to keep backward compatibility 37 | return { 38 | count: occurrences, 39 | id: nameList, 40 | hash: hashList 41 | }; 42 | }; 43 | 44 | const variables = function (targets, attribute) { 45 | // Storage objects 46 | let occurrences = 0; 47 | let idList = []; 48 | 49 | // Cloud variables are a type of variable 50 | const isCloud = (attribute === 'cloud'); 51 | if (isCloud) attribute = 'variables'; 52 | 53 | for (let t in targets) { 54 | for (let a in targets[t][attribute]) { 55 | const variable = targets[t][attribute][a]; 56 | if (isCloud && (variable.length !== 3 || !variable[2])) continue; 57 | occurrences++; 58 | idList.push(variable[0]); 59 | } 60 | } 61 | 62 | return { 63 | count: occurrences, 64 | id: idList 65 | }; 66 | }; 67 | 68 | // Iterate over targets, extract attribute, and aggregate results 69 | const extract = function (targets, attribute, id, hash) { 70 | // Storage objects 71 | let occurrences = 0; 72 | let idList = []; 73 | let hashList = []; 74 | 75 | for (let t in targets) { 76 | for (let a in targets[t][attribute]) { 77 | const asset = targets[t][attribute][a]; 78 | occurrences++; 79 | if (typeof id !== 'undefined') idList.push(asset[id]); 80 | if (typeof hash !== 'undefined') hashList.push(asset[hash]); 81 | } 82 | } 83 | 84 | const result = {count: occurrences}; 85 | if (typeof id !== 'undefined') result.id = idList; 86 | if (typeof hash !== 'undefined') result.hash = hashList; 87 | return result; 88 | }; 89 | 90 | const sprites = function (targets) { 91 | return { 92 | count: targets.length - 1 93 | }; 94 | }; 95 | 96 | const blocks = function (targets) { 97 | // Storage object 98 | let result = []; 99 | 100 | /** 101 | * Determine if a argument is the name of a known cloud variable. 102 | * @param {string} arg Argument (variable name) 103 | * @return {boolean} Is cloud variable? 104 | */ 105 | const isArgCloudVar = function (arg) { 106 | // Validate argument 107 | if (typeof arg !== 'string') return false; 108 | 109 | // Check first target (stage) to determine if arg is a cloud variable id 110 | const stage = targets[0]; 111 | if (typeof stage.variables[arg] !== 'undefined') { 112 | return stage.variables[arg].length === 3 && stage.variables[arg][2]; 113 | } 114 | }; 115 | 116 | // Iterate over all targets and push block opcodes to storage object 117 | for (let t in targets) { 118 | for (let a in targets[t].blocks) { 119 | const block = targets[t].blocks[a]; 120 | let opcode = block.opcode; 121 | 122 | // Check for primitive blocks which don't have the opcode field 123 | if (typeof opcode === 'undefined') { 124 | switch (block[0]) { 125 | case (12): 126 | opcode = 'data_variable'; 127 | break; 128 | case (13): 129 | opcode = 'data_listcontents'; 130 | break; 131 | } 132 | } 133 | 134 | // Get opcode and check variable manipulation for the presence of 135 | // cloud variables 136 | if (opcode === 'data_setvariableto' || opcode === 'data_changevariableby') { 137 | if (isArgCloudVar(block?.fields?.VARIABLE?.[1])) { 138 | opcode += '_cloud'; 139 | } 140 | } 141 | 142 | if (!block.shadow) result.push(opcode); 143 | } 144 | } 145 | 146 | // Calculate block frequency 147 | const freq = utility.frequency(result); 148 | 149 | // Return summary 150 | return { 151 | count: result.length, 152 | unique: Object.keys(freq).length, 153 | id: result, 154 | frequency: freq 155 | }; 156 | }; 157 | 158 | const extensions = function (list) { 159 | if (Array.isArray(list)) { 160 | return {count: list.length, id: list}; 161 | } 162 | 163 | return {count: 0, id: []}; 164 | }; 165 | 166 | const metadata = function (meta) { 167 | let obj = {}; 168 | for (const key of ['semver', 'vm', 'agent', 'origin']) { 169 | if (Object.hasOwn(meta, key)) { 170 | obj[key] = meta[key]; 171 | } 172 | } 173 | return obj; 174 | }; 175 | 176 | const stageTargets = function (targets) { 177 | return targets.filter((target) => target.isStage); 178 | }; 179 | 180 | module.exports = function (project, callback) { 181 | const meta = { 182 | scripts: scripts(project.targets), 183 | variables: variables(project.targets, 'variables'), 184 | cloud: variables(project.targets, 'cloud'), 185 | lists: variables(project.targets, 'lists'), 186 | comments: extract(project.targets, 'comments'), 187 | sounds: extract(project.targets, 'sounds', 'name', 'md5ext'), 188 | costumes: costumes(project.targets), 189 | // backdrops are costumes on the stage target 190 | backdrops: costumes(stageTargets(project.targets)), 191 | sprites: sprites(project.targets), 192 | blocks: blocks(project.targets), 193 | extensions: extensions(project.extensions), 194 | meta: metadata(project.meta), 195 | projectVersion: project.projectVersion, 196 | isBundle: project.isBundle 197 | }; 198 | 199 | callback(null, meta); 200 | }; 201 | -------------------------------------------------------------------------------- /lib/utility.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility methods for costructing Scratch project summaries. 3 | */ 4 | class Utility { 5 | /** 6 | * Tallies term frequency from an array of strings. 7 | * @param {array} input Array of strings 8 | * @return {object} Frequency information 9 | */ 10 | static frequency (input) { 11 | const result = Object.create(null); 12 | 13 | for (let i in input) { 14 | var term = input[i]; 15 | if (typeof result[term] === 'undefined') result[term] = 0; 16 | result[term]++; 17 | } 18 | 19 | return result; 20 | } 21 | } 22 | 23 | module.exports = Utility; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scratch-analysis", 3 | "version": "2.4.1", 4 | "description": "Analysis tool for summarizing the structure, composition, and complexity of Scratch programs.", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "lib": "lib", 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "prepare": "husky || true", 12 | "test": "npm run test:lint && npm run test:unit", 13 | "test:lint": "eslint .", 14 | "test:unit": "tap --reporter nyan test/unit/*.js --statements=97 --branches=97" 15 | }, 16 | "author": "Scratch Foundation", 17 | "license": "BSD-3-Clause", 18 | "dependencies": { 19 | "scratch-parser": "5.0.0", 20 | "scratch-sb1-converter": "2.0.50" 21 | }, 22 | "devDependencies": { 23 | "@babel/eslint-parser": "^7.5.4", 24 | "@commitlint/cli": "^19.7.1", 25 | "@commitlint/config-conventional": "^19.7.1", 26 | "eslint": "^8.16.0", 27 | "eslint-config-scratch": "^7.0.0", 28 | "husky": "^9.1.7", 29 | "scratch-semantic-release-config": "1.0.8", 30 | "tap": "^16.2.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'scratch-semantic-release-config', 3 | branches: [ 4 | { 5 | name: 'master' 6 | // default channel 7 | } 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /test/fixtures/invalid/garbage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/invalid/garbage.jpg -------------------------------------------------------------------------------- /test/fixtures/sb1/AllBlocks-Scratch1.4.sb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb1/AllBlocks-Scratch1.4.sb -------------------------------------------------------------------------------- /test/fixtures/sb2/cloud.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb2/cloud.sb2 -------------------------------------------------------------------------------- /test/fixtures/sb2/cloud_complex.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb2/cloud_complex.sb2 -------------------------------------------------------------------------------- /test/fixtures/sb2/cloud_opcodes.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb2/cloud_opcodes.sb2 -------------------------------------------------------------------------------- /test/fixtures/sb2/complex.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb2/complex.sb2 -------------------------------------------------------------------------------- /test/fixtures/sb2/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "objName": "Stage", 3 | "sounds": [{ 4 | "soundName": "pop", 5 | "soundID": -1, 6 | "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", 7 | "sampleCount": 258, 8 | "rate": 11025, 9 | "format": "" 10 | }], 11 | "costumes": [{ 12 | "costumeName": "backdrop1", 13 | "baseLayerID": -1, 14 | "baseLayerMD5": "739b5e2a2435f6e1ec2993791b423146.png", 15 | "bitmapResolution": 1, 16 | "rotationCenterX": 240, 17 | "rotationCenterY": 180 18 | }], 19 | "currentCostumeIndex": 0, 20 | "penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png", 21 | "penLayerID": -1, 22 | "tempoBPM": 60, 23 | "videoAlpha": 0.5, 24 | "children": [{ 25 | "objName": "Sprite1", 26 | "sounds": [{ 27 | "soundName": "meow", 28 | "soundID": -1, 29 | "md5": "83c36d806dc92327b9e7049a565c6bff.wav", 30 | "sampleCount": 18688, 31 | "rate": 22050, 32 | "format": "" 33 | }], 34 | "costumes": [{ 35 | "costumeName": "costume1", 36 | "baseLayerID": -1, 37 | "baseLayerMD5": "09dc888b0b7df19f70d81588ae73420e.svg", 38 | "bitmapResolution": 1, 39 | "rotationCenterX": 47, 40 | "rotationCenterY": 55 41 | }, 42 | { 43 | "costumeName": "costume2", 44 | "baseLayerID": -1, 45 | "baseLayerMD5": "3696356a03a8d938318876a593572843.svg", 46 | "bitmapResolution": 1, 47 | "rotationCenterX": 47, 48 | "rotationCenterY": 55 49 | }], 50 | "currentCostumeIndex": 0, 51 | "scratchX": 0, 52 | "scratchY": 0, 53 | "scale": 1, 54 | "direction": 90, 55 | "rotationStyle": "normal", 56 | "isDraggable": false, 57 | "indexInLibrary": 1, 58 | "visible": true, 59 | "spriteInfo": { 60 | } 61 | }], 62 | "info": { 63 | "videoOn": false, 64 | "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/50.0.2661.102 Safari\/537.36", 65 | "swfVersion": "v446", 66 | "scriptCount": 0, 67 | "spriteCount": 1, 68 | "hasCloudData": false, 69 | "flashVersion": "MAC 21,0,0,242" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/fixtures/sb2/default.sb2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb2/default.sb2 -------------------------------------------------------------------------------- /test/fixtures/sb2/infoMissing.json: -------------------------------------------------------------------------------- 1 | { 2 | "objName": "Stage", 3 | "costumes": [], 4 | "currentCostumeIndex": 0, 5 | "penLayerMD5": "l.png", 6 | "tempoBPM": 60, 7 | "children": [] 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/sb2/invalid-costumes.json: -------------------------------------------------------------------------------- 1 | { 2 | "objName": "Stage", 3 | "sounds": [{ 4 | "soundName": "pop", 5 | "soundID": -1, 6 | "md5": "83a9787d4cb6f3b7632b4ddfebf74367.wav", 7 | "sampleCount": 258, 8 | "rate": 11025, 9 | "format": "" 10 | }], 11 | "costumes": { 12 | "invalid": "This should be an array but is an object instead" 13 | }, 14 | "currentCostumeIndex": 0, 15 | "penLayerMD5": "5c81a336fab8be57adc039a8a2b33ca9.png", 16 | "penLayerID": -1, 17 | "children": [], 18 | "info": { 19 | "videoOn": false, 20 | "scriptCount": 0, 21 | "spriteCount": 0 22 | } 23 | } -------------------------------------------------------------------------------- /test/fixtures/sb3/badExtensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "isStage": true, 5 | "name": "Stage", 6 | "variables": { 7 | "`jEk@4|i[#Fk?(8x)AV.-my variable": [ 8 | "my variable", 9 | 0 10 | ] 11 | }, 12 | "lists": {}, 13 | "broadcasts": {}, 14 | "blocks": {}, 15 | "comments": {}, 16 | "currentCostume": 0, 17 | "costumes": [ 18 | { 19 | "assetId": "cd21514d0531fdffb22204e0ec5ed84a", 20 | "name": "backdrop1", 21 | "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", 22 | "dataFormat": "svg", 23 | "rotationCenterX": 240, 24 | "rotationCenterY": 180 25 | } 26 | ], 27 | "sounds": [ 28 | { 29 | "assetId": "83a9787d4cb6f3b7632b4ddfebf74367", 30 | "name": "pop", 31 | "dataFormat": "wav", 32 | "format": "", 33 | "rate": 44100, 34 | "sampleCount": 1032, 35 | "md5ext": "83a9787d4cb6f3b7632b4ddfebf74367.wav" 36 | } 37 | ], 38 | "volume": 100, 39 | "layerOrder": 0, 40 | "tempo": 60, 41 | "videoTransparency": 50, 42 | "videoState": "on", 43 | "textToSpeechLanguage": null 44 | }, 45 | { 46 | "isStage": false, 47 | "name": "Sprite1", 48 | "variables": {}, 49 | "lists": {}, 50 | "broadcasts": {}, 51 | "blocks": {}, 52 | "comments": {}, 53 | "currentCostume": 0, 54 | "costumes": [ 55 | { 56 | "assetId": "b7853f557e4426412e64bb3da6531a99", 57 | "name": "costume1", 58 | "bitmapResolution": 1, 59 | "md5ext": "b7853f557e4426412e64bb3da6531a99.svg", 60 | "dataFormat": "svg", 61 | "rotationCenterX": 48, 62 | "rotationCenterY": 50 63 | }, 64 | { 65 | "assetId": "e6ddc55a6ddd9cc9d84fe0b4c21e016f", 66 | "name": "costume2", 67 | "bitmapResolution": 1, 68 | "md5ext": "e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg", 69 | "dataFormat": "svg", 70 | "rotationCenterX": 46, 71 | "rotationCenterY": 53 72 | } 73 | ], 74 | "sounds": [ 75 | { 76 | "assetId": "83c36d806dc92327b9e7049a565c6bff", 77 | "name": "Meow", 78 | "dataFormat": "wav", 79 | "format": "", 80 | "rate": 44100, 81 | "sampleCount": 37376, 82 | "md5ext": "83c36d806dc92327b9e7049a565c6bff.wav" 83 | } 84 | ], 85 | "volume": 100, 86 | "layerOrder": 1, 87 | "visible": true, 88 | "x": 0, 89 | "y": 0, 90 | "size": 100, 91 | "direction": 90, 92 | "draggable": false, 93 | "rotationStyle": "all around" 94 | } 95 | ], 96 | "monitors": [], 97 | "meta": { 98 | "semver": "3.0.0", 99 | "vm": "0.2.0-prerelease.20181217191056", 100 | "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36", 101 | "origin": "test.scratch.mit.edu" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/fixtures/sb3/cloud.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb3/cloud.sb3 -------------------------------------------------------------------------------- /test/fixtures/sb3/cloud_complex.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb3/cloud_complex.sb3 -------------------------------------------------------------------------------- /test/fixtures/sb3/cloud_opcodes.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb3/cloud_opcodes.sb3 -------------------------------------------------------------------------------- /test/fixtures/sb3/complex.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb3/complex.sb3 -------------------------------------------------------------------------------- /test/fixtures/sb3/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "isStage": true, 5 | "name": "Stage", 6 | "variables": { 7 | "`jEk@4|i[#Fk?(8x)AV.-my variable": [ 8 | "my variable", 9 | 0 10 | ] 11 | }, 12 | "lists": {}, 13 | "broadcasts": {}, 14 | "blocks": {}, 15 | "comments": {}, 16 | "currentCostume": 0, 17 | "costumes": [ 18 | { 19 | "assetId": "cd21514d0531fdffb22204e0ec5ed84a", 20 | "name": "backdrop1", 21 | "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", 22 | "dataFormat": "svg", 23 | "rotationCenterX": 240, 24 | "rotationCenterY": 180 25 | } 26 | ], 27 | "sounds": [ 28 | { 29 | "assetId": "83a9787d4cb6f3b7632b4ddfebf74367", 30 | "name": "pop", 31 | "dataFormat": "wav", 32 | "format": "", 33 | "rate": 44100, 34 | "sampleCount": 1032, 35 | "md5ext": "83a9787d4cb6f3b7632b4ddfebf74367.wav" 36 | } 37 | ], 38 | "volume": 100, 39 | "layerOrder": 0, 40 | "tempo": 60, 41 | "videoTransparency": 50, 42 | "videoState": "on", 43 | "textToSpeechLanguage": null 44 | }, 45 | { 46 | "isStage": false, 47 | "name": "Sprite1", 48 | "variables": {}, 49 | "lists": {}, 50 | "broadcasts": {}, 51 | "blocks": {}, 52 | "comments": {}, 53 | "currentCostume": 0, 54 | "costumes": [ 55 | { 56 | "assetId": "b7853f557e4426412e64bb3da6531a99", 57 | "name": "costume1", 58 | "bitmapResolution": 1, 59 | "md5ext": "b7853f557e4426412e64bb3da6531a99.svg", 60 | "dataFormat": "svg", 61 | "rotationCenterX": 48, 62 | "rotationCenterY": 50 63 | }, 64 | { 65 | "assetId": "e6ddc55a6ddd9cc9d84fe0b4c21e016f", 66 | "name": "costume2", 67 | "bitmapResolution": 1, 68 | "md5ext": "e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg", 69 | "dataFormat": "svg", 70 | "rotationCenterX": 46, 71 | "rotationCenterY": 53 72 | }, 73 | { 74 | "assetId": "d27716e022fb5f747d7b09fe6eeeca06", 75 | "name": "costume_without_md5ext", 76 | "bitmapResolution": 1, 77 | "dataFormat": "svg", 78 | "rotationCenterX": 71, 79 | "rotationCenterY": 107 80 | } 81 | ], 82 | "sounds": [ 83 | { 84 | "assetId": "83c36d806dc92327b9e7049a565c6bff", 85 | "name": "Meow", 86 | "dataFormat": "wav", 87 | "format": "", 88 | "rate": 44100, 89 | "sampleCount": 37376, 90 | "md5ext": "83c36d806dc92327b9e7049a565c6bff.wav" 91 | } 92 | ], 93 | "volume": 100, 94 | "layerOrder": 1, 95 | "visible": true, 96 | "x": 0, 97 | "y": 0, 98 | "size": 100, 99 | "direction": 90, 100 | "draggable": false, 101 | "rotationStyle": "all around" 102 | } 103 | ], 104 | "monitors": [], 105 | "extensions": [], 106 | "meta": { 107 | "semver": "3.0.0", 108 | "vm": "0.2.0-prerelease.20181217191056", 109 | "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36", 110 | "origin": "test.scratch.mit.edu" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/fixtures/sb3/default.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb3/default.sb3 -------------------------------------------------------------------------------- /test/fixtures/sb3/extensions.sb3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scratchfoundation/scratch-analysis/3ba56a0e4fdde7ad5c06e8f4c107200fca701d50/test/fixtures/sb3/extensions.sb3 -------------------------------------------------------------------------------- /test/fixtures/sb3/extensionsObject.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [], 3 | "monitors": [], 4 | "extensions": {}, 5 | "meta": { 6 | "semver": "3.0.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/sb3/missingVariableField.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "isStage": true, 5 | "name": "Stage", 6 | "variables": {}, 7 | "blocks": { 8 | "a": { 9 | "opcode": "data_setvariableto", 10 | "fields": {} 11 | } 12 | }, 13 | "costumes": [ 14 | { 15 | "assetId": "cd21514d0531fdffb22204e0ec5ed84a", 16 | "name": "backdrop1", 17 | "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", 18 | "dataFormat": "svg", 19 | "rotationCenterX": 240, 20 | "rotationCenterY": 180 21 | } 22 | ], 23 | "sounds": [] 24 | } 25 | ], 26 | "monitors": [], 27 | "extensions": [], 28 | "meta": { 29 | "semver": "3.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/fixtures/sb3/primitiveVariableAndListBlocks.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "isStage": true, 5 | "name": "Stage", 6 | "variables": { 7 | "T1[{JjfXy_y{K@EQkA?1": [ 8 | "my_variable", 9 | "0" 10 | ] 11 | }, 12 | "lists": { 13 | "HewanSoehucCjFovU@^H": [ 14 | "my_list", 15 | [] 16 | ] 17 | }, 18 | "broadcasts": {}, 19 | "blocks": {}, 20 | "comments": {}, 21 | "currentCostume": 0, 22 | "costumes": [ 23 | { 24 | "assetId": "cd21514d0531fdffb22204e0ec5ed84a", 25 | "name": "backdrop1", 26 | "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg", 27 | "dataFormat": "svg", 28 | "rotationCenterX": 240, 29 | "rotationCenterY": 180 30 | } 31 | ], 32 | "sounds": [ 33 | { 34 | "assetId": "83a9787d4cb6f3b7632b4ddfebf74367", 35 | "name": "pop", 36 | "dataFormat": "wav", 37 | "format": "", 38 | "rate": 44100, 39 | "sampleCount": 1032, 40 | "md5ext": "83a9787d4cb6f3b7632b4ddfebf74367.wav" 41 | } 42 | ], 43 | "volume": 100, 44 | "layerOrder": 0, 45 | "tempo": 60, 46 | "videoTransparency": 50, 47 | "videoState": "on", 48 | "textToSpeechLanguage": null 49 | }, 50 | { 51 | "isStage": false, 52 | "name": "Sprite1", 53 | "variables": {}, 54 | "lists": {}, 55 | "broadcasts": {}, 56 | "blocks": { 57 | "wS7V(|TE2G9v@AX8BYK~": [ 58 | 13, 59 | "my_list", 60 | "HewanSoehucCjFovU@^H", 61 | 713, 62 | 604 63 | ], 64 | "|#CTw):^,.,NSY)[Tnb?": { 65 | "opcode": "motion_changexby", 66 | "next": null, 67 | "parent": "^m#)|6_G10cLNp(ZlZv(", 68 | "inputs": { 69 | "DX": [ 70 | 3, 71 | [ 72 | 12, 73 | "my_variable", 74 | "T1[{JjfXy_y{K@EQkA?1" 75 | ], 76 | [ 77 | 4, 78 | "10" 79 | ] 80 | ] 81 | }, 82 | "fields": {}, 83 | "shadow": false, 84 | "topLevel": false 85 | }, 86 | "2_SCXE]6;tCt$ZcG|GQ(": [ 87 | 12, 88 | "my_variable", 89 | "T1[{JjfXy_y{K@EQkA?1", 90 | 322, 91 | 449 92 | ] 93 | }, 94 | "comments": {}, 95 | "currentCostume": 0, 96 | "costumes": [ 97 | { 98 | "assetId": "b7853f557e4426412e64bb3da6531a99", 99 | "name": "costume1", 100 | "bitmapResolution": 1, 101 | "md5ext": "b7853f557e4426412e64bb3da6531a99.svg", 102 | "dataFormat": "svg", 103 | "rotationCenterX": 48, 104 | "rotationCenterY": 50 105 | } 106 | ], 107 | "sounds": [ 108 | { 109 | "assetId": "83c36d806dc92327b9e7049a565c6bff", 110 | "name": "Meow", 111 | "dataFormat": "wav", 112 | "format": "", 113 | "rate": 44100, 114 | "sampleCount": 37376, 115 | "md5ext": "83c36d806dc92327b9e7049a565c6bff.wav" 116 | } 117 | ], 118 | "volume": 100, 119 | "layerOrder": 1, 120 | "visible": true, 121 | "x": 0, 122 | "y": 0, 123 | "size": 100, 124 | "direction": 90, 125 | "draggable": false, 126 | "rotationStyle": "all around" 127 | } 128 | ], 129 | "monitors": [], 130 | "meta": { 131 | "semver": "3.0.0", 132 | "vm": "0.2.0-prerelease.20181217191056", 133 | "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36", 134 | "origin": "test.scratch.mit.edu" 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /test/unit/cloud.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const test = require('tap').test; 4 | const analysis = require('../../lib/index'); 5 | 6 | const sb2 = fs.readFileSync( 7 | path.resolve(__dirname, '../fixtures/sb2/cloud.sb2') 8 | ); 9 | const sb3 = fs.readFileSync( 10 | path.resolve(__dirname, '../fixtures/sb3/cloud.sb3') 11 | ); 12 | const sb2Complex = fs.readFileSync( 13 | path.resolve(__dirname, '../fixtures/sb2/cloud_complex.sb2') 14 | ); 15 | const sb3Complex = fs.readFileSync( 16 | path.resolve(__dirname, '../fixtures/sb3/cloud_complex.sb3') 17 | ); 18 | 19 | test('sb2', t => { 20 | analysis(sb2, (err, result) => { 21 | t.ok(typeof err === 'undefined' || err === null); 22 | t.type(result, 'object'); 23 | t.type(result.cloud, 'object'); 24 | t.equal(result.cloud.count, 1); 25 | t.same(result.cloud.id, ['☁ baz']); 26 | t.end(); 27 | }); 28 | }); 29 | 30 | test('sb3', t => { 31 | analysis(sb3, (err, result) => { 32 | t.ok(typeof err === 'undefined' || err === null); 33 | t.type(result, 'object'); 34 | t.type(result.cloud, 'object'); 35 | t.equal(result.cloud.count, 1); 36 | t.same(result.cloud.id, ['☁ baz']); 37 | t.end(); 38 | }); 39 | }); 40 | 41 | test('sb2 complex', t => { 42 | analysis(sb2Complex, (err, result) => { 43 | t.ok(typeof err === 'undefined' || err === null); 44 | t.type(result, 'object'); 45 | t.type(result.cloud, 'object'); 46 | t.equal(result.cloud.count, 8); 47 | t.same(result.cloud.id, [ 48 | '☁ Player_1', 49 | '☁ Player_2', 50 | '☁ Player_3', 51 | '☁ Player_4', 52 | '☁ Player_5', 53 | '☁ GameData', 54 | '☁ Player_6', 55 | '☁ SAVE_DATA2' 56 | ]); 57 | t.end(); 58 | }); 59 | }); 60 | 61 | test('sb3 complex', t => { 62 | analysis(sb3Complex, (err, result) => { 63 | t.ok(typeof err === 'undefined' || err === null); 64 | t.type(result, 'object'); 65 | t.type(result.cloud, 'object'); 66 | t.equal(result.cloud.count, 8); 67 | t.same(result.cloud.id, [ 68 | '☁ Player_1', 69 | '☁ Player_2', 70 | '☁ Player_3', 71 | '☁ Player_4', 72 | '☁ Player_5', 73 | '☁ GameData', 74 | '☁ Player_6', 75 | '☁ SAVE_DATA2' 76 | ]); 77 | t.end(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/unit/cloud_opcodes.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const test = require('tap').test; 4 | const analysis = require('../../lib/index'); 5 | 6 | const sb2 = fs.readFileSync( 7 | path.resolve(__dirname, '../fixtures/sb2/cloud_opcodes.sb2') 8 | ); 9 | const sb3 = fs.readFileSync( 10 | path.resolve(__dirname, '../fixtures/sb3/cloud_opcodes.sb3') 11 | ); 12 | 13 | test('sb2', t => { 14 | analysis(sb2, (err, result) => { 15 | t.ok(typeof err === 'undefined' || err === null); 16 | t.type(result, 'object'); 17 | t.type(result.blocks, 'object'); 18 | t.type(result.blocks.id, 'object'); 19 | t.same(result.blocks.id, [ 20 | 'whenGreenFlag', 21 | 'doForever', 22 | 'setVar:to:', 23 | 'randomFrom:to:', 24 | 'changeVar:by:', 25 | 'setVar:to:', 26 | 'randomFrom:to:', 27 | 'changeVar:by:', 28 | 'setVar:to:cloud:', 29 | 'randomFrom:to:', 30 | 'changeVar:by:cloud:', 31 | 'wait:elapsed:from:' 32 | ]); 33 | t.end(); 34 | }); 35 | }); 36 | 37 | test('sb3', t => { 38 | analysis(sb3, (err, result) => { 39 | t.ok(typeof err === 'undefined' || err === null); 40 | t.type(result, 'object'); 41 | t.type(result.blocks, 'object'); 42 | t.type(result.blocks.id, 'object'); 43 | t.same(result.blocks.id, [ 44 | 'event_whenflagclicked', 45 | 'control_forever', 46 | 'control_wait', 47 | 'data_setvariableto', 48 | 'data_setvariableto', 49 | 'data_setvariableto_cloud', 50 | 'operator_random', 51 | 'operator_random', 52 | 'operator_random', 53 | 'data_changevariableby', 54 | 'data_changevariableby', 55 | 'data_changevariableby_cloud' 56 | ]); 57 | t.end(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/unit/error.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const test = require('tap').test; 4 | const analysis = require('../../lib/index'); 5 | 6 | const invalidBinary = fs.readFileSync( 7 | path.resolve(__dirname, '../fixtures/invalid/garbage.jpg') 8 | ); 9 | 10 | test('invalid object', t => { 11 | analysis('{}', (err, result) => { 12 | t.type(err, 'object'); 13 | t.type(result, 'undefined'); 14 | t.end(); 15 | }); 16 | }); 17 | 18 | test('invalid binary', t => { 19 | analysis(invalidBinary, (err, result) => { 20 | t.type(err, 'string'); 21 | t.type(result, 'undefined'); 22 | t.end(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/unit/sb1.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const test = require('tap').test; 4 | const analysis = require('../../lib/index'); 5 | // using the sb1 directly to bypass scratch-parser and excersise 6 | // logic targeting broken project files 7 | const {SB1Analyzer} = require('../../lib/sb1'); 8 | 9 | const allBlocksBinary = fs.readFileSync( 10 | path.resolve(__dirname, '../fixtures/sb1/AllBlocks-Scratch1.4.sb') 11 | ); 12 | 13 | test('project using all block types', t => { 14 | analysis(allBlocksBinary, (err, result) => { 15 | t.ok(typeof err === 'undefined' || err === null); 16 | t.type(result, 'object'); 17 | 18 | t.type(result.scripts, 'object'); 19 | t.equal(result.scripts.count, 11); 20 | 21 | t.type(result.variables, 'object'); 22 | t.equal(result.variables.count, 8); 23 | t.same(result.variables.id, [ 24 | 'Motion Blocks', 25 | 'Control Blocks', 26 | 'Sensing Blocks', 27 | 'Operators Blocks', 28 | 'Looks Blocks', 29 | 'Variables Blocks', 30 | 'Pen Blocks', 31 | 'Sound Blocks' 32 | ]); 33 | 34 | t.type(result.lists, 'object'); 35 | t.equal(result.lists.count, 1); 36 | t.same(result.lists.id, ['a list']); 37 | 38 | t.type(result.comments, 'object'); 39 | t.equal(result.comments.count, 0); 40 | 41 | t.type(result.sounds, 'object'); 42 | t.equal(result.sounds.count, 2); 43 | t.same(result.sounds.id, [ 44 | 'pop', 45 | 'meow' 46 | ]); 47 | t.same(result.sounds.hash, [ 48 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 49 | '83c36d806dc92327b9e7049a565c6bff.wav' 50 | ]); 51 | 52 | t.type(result.costumes, 'object'); 53 | t.equal(result.costumes.count, 3); 54 | t.same(result.costumes.id, [ 55 | 'background1', 56 | 'costume1', 57 | 'costume2' 58 | ]); 59 | t.same(result.costumes.hash, [ 60 | 'be2aa84eeac485ab8d9ca51294cd926e.png', 61 | '87b6d14fce8842fb56155dc7f6496308.png', 62 | '07a12efdb3cd7ffc94b55563268367b1.png' 63 | ]); 64 | 65 | t.type(result.backdrops, 'object'); 66 | t.equal(result.backdrops.count, 1); 67 | t.same(result.backdrops.id, [ 68 | 'background1' 69 | ]); 70 | t.same(result.backdrops.hash, [ 71 | 'be2aa84eeac485ab8d9ca51294cd926e.png' 72 | ]); 73 | 74 | t.type(result.sprites, 'object'); 75 | t.equal(result.sprites.count, 1); 76 | 77 | t.type(result.blocks, 'object'); 78 | t.equal(result.blocks.count, 156); 79 | t.equal(result.blocks.unique, 114); 80 | t.same(result.blocks.id.slice(0, 3), [ 81 | 'setVar:to:', 82 | 'changeVar:by:', 83 | 'showVariable:' 84 | ]); 85 | t.type(result.blocks.frequency, 'object'); 86 | t.equal(result.blocks.frequency['setVar:to:'], 40); 87 | 88 | t.type(result.extensions, 'object'); 89 | t.equal(result.extensions.count, 0); 90 | t.same(result.extensions.id, []); 91 | 92 | t.type(result.meta, 'object'); 93 | t.same(result.meta, {}); 94 | 95 | t.type(result.projectVersion, 'number'); 96 | t.equal(result.projectVersion, 1); 97 | 98 | t.type(result.isBundle, 'boolean'); 99 | t.equal(result.isBundle, true); 100 | 101 | t.end(); 102 | }); 103 | }); 104 | 105 | test('malformed project', t => { 106 | // A buffer with a correct SB1 signature but no content 107 | const malformedBinary = Buffer.from('Scr', 'ascii'); 108 | 109 | analysis(malformedBinary, (err, result) => { 110 | t.ok(err); 111 | t.type(err, 'object'); 112 | t.equal(err.message, 'Non-ascii character in FixedAsciiString'); 113 | t.type(result, 'undefined'); 114 | t.end(); 115 | }); 116 | }); 117 | 118 | test('syntactically valid SB1 project that does not pass schema validation after conversion', t => { 119 | const mockValidate = (_isSprite, _project, callback) => { 120 | callback(new Error('Project JSON is invalid')); 121 | }; 122 | const sb1 = new SB1Analyzer(mockValidate); 123 | 124 | sb1.analyze(allBlocksBinary, (err, result) => { 125 | t.ok(err); 126 | t.type(err, 'object'); 127 | t.equal(err.message, 'Project JSON is invalid'); 128 | t.type(result, 'undefined'); 129 | t.end(); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/unit/sb2.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const test = require('tap').test; 4 | const analysis = require('../../lib/index'); 5 | // using the sb2 directly to bypass scratch-parser and excersise 6 | // logic targeting broken project files 7 | const sb2 = require('../../lib/sb2'); 8 | 9 | const defaultObject = fs.readFileSync( 10 | path.resolve(__dirname, '../fixtures/sb2/default.json') 11 | ); 12 | const defaultBinary = fs.readFileSync( 13 | path.resolve(__dirname, '../fixtures/sb2/default.sb2') 14 | ); 15 | const complexBinary = fs.readFileSync( 16 | path.resolve(__dirname, '../fixtures/sb2/complex.sb2') 17 | ); 18 | 19 | const invalidCostumes = fs.readFileSync( 20 | path.resolve(__dirname, '../fixtures/sb2/invalid-costumes.json') 21 | ); 22 | 23 | const missingInfo = fs.readFileSync( 24 | path.resolve(__dirname, '../fixtures/sb2/infoMissing.json') 25 | ); 26 | 27 | test('default (object)', t => { 28 | analysis(defaultObject, (err, result) => { 29 | t.ok(typeof err === 'undefined' || err === null); 30 | t.type(result, 'object'); 31 | 32 | t.type(result.scripts, 'object'); 33 | t.equal(result.scripts.count, 0); 34 | 35 | t.type(result.variables, 'object'); 36 | t.equal(result.variables.count, 0); 37 | t.same(result.variables.id, []); 38 | 39 | t.type(result.lists, 'object'); 40 | t.equal(result.lists.count, 0); 41 | t.same(result.lists.id, []); 42 | 43 | t.type(result.comments, 'object'); 44 | t.equal(result.comments.count, 0); 45 | 46 | t.type(result.sounds, 'object'); 47 | t.equal(result.sounds.count, 2); 48 | t.same(result.sounds.id, [ 49 | 'pop', 50 | 'meow' 51 | ]); 52 | t.same(result.sounds.hash, [ 53 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 54 | '83c36d806dc92327b9e7049a565c6bff.wav' 55 | ]); 56 | 57 | t.type(result.costumes, 'object'); 58 | t.equal(result.costumes.count, 3); 59 | t.same(result.costumes.id, [ 60 | 'backdrop1', 61 | 'costume1', 62 | 'costume2' 63 | ]); 64 | t.same(result.costumes.hash, [ 65 | '739b5e2a2435f6e1ec2993791b423146.png', 66 | '09dc888b0b7df19f70d81588ae73420e.svg', 67 | '3696356a03a8d938318876a593572843.svg' 68 | ]); 69 | 70 | t.type(result.backdrops, 'object'); 71 | t.equal(result.backdrops.count, 1); 72 | t.same(result.backdrops.id, [ 73 | 'backdrop1' 74 | ]); 75 | t.same(result.backdrops.hash, [ 76 | '739b5e2a2435f6e1ec2993791b423146.png' 77 | ]); 78 | 79 | t.type(result.sprites, 'object'); 80 | t.equal(result.sprites.count, 1); 81 | 82 | t.type(result.blocks, 'object'); 83 | t.equal(result.blocks.count, 0); 84 | t.equal(result.blocks.unique, 0); 85 | t.same(result.blocks.id, []); 86 | t.same(result.blocks.frequency, {}); 87 | 88 | t.type(result.extensions, 'object'); 89 | t.equal(result.extensions.count, 0); 90 | t.same(result.extensions.id, []); 91 | 92 | t.type(result.meta, 'object'); 93 | t.same(result.meta, {}); 94 | 95 | t.type(result.projectVersion, 'number'); 96 | t.equal(result.projectVersion, 2); 97 | 98 | t.type(result.isBundle, 'boolean'); 99 | t.equal(result.isBundle, false); 100 | 101 | t.end(); 102 | }); 103 | }); 104 | 105 | test('default (binary)', t => { 106 | analysis(defaultBinary, (err, result) => { 107 | t.ok(typeof err === 'undefined' || err === null); 108 | t.type(result, 'object'); 109 | 110 | t.type(result.scripts, 'object'); 111 | t.equal(result.scripts.count, 0); 112 | 113 | t.type(result.variables, 'object'); 114 | t.equal(result.variables.count, 0); 115 | t.same(result.variables.id, []); 116 | 117 | t.type(result.lists, 'object'); 118 | t.equal(result.lists.count, 0); 119 | t.same(result.lists.id, []); 120 | 121 | t.type(result.comments, 'object'); 122 | t.equal(result.comments.count, 0); 123 | 124 | t.type(result.sounds, 'object'); 125 | t.equal(result.sounds.count, 2); 126 | t.same(result.sounds.id, [ 127 | 'pop', 128 | 'meow' 129 | ]); 130 | t.same(result.sounds.hash, [ 131 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 132 | '83c36d806dc92327b9e7049a565c6bff.wav' 133 | ]); 134 | 135 | t.type(result.costumes, 'object'); 136 | t.equal(result.costumes.count, 3); 137 | t.same(result.costumes.id, [ 138 | 'backdrop1', 139 | 'costume1', 140 | 'costume2' 141 | ]); 142 | t.same(result.costumes.hash, [ 143 | '739b5e2a2435f6e1ec2993791b423146.png', 144 | 'f9a1c175dbe2e5dee472858dd30d16bb.svg', 145 | '6e8bd9ae68fdb02b7e1e3df656a75635.svg' 146 | ]); 147 | 148 | t.type(result.backdrops, 'object'); 149 | t.equal(result.backdrops.count, 1); 150 | t.same(result.backdrops.id, [ 151 | 'backdrop1' 152 | ]); 153 | t.same(result.backdrops.hash, [ 154 | '739b5e2a2435f6e1ec2993791b423146.png' 155 | ]); 156 | 157 | t.type(result.sprites, 'object'); 158 | t.equal(result.sprites.count, 1); 159 | 160 | t.type(result.blocks, 'object'); 161 | t.equal(result.blocks.count, 0); 162 | t.equal(result.blocks.unique, 0); 163 | t.same(result.blocks.id, []); 164 | t.same(result.blocks.frequency, {}); 165 | 166 | t.type(result.extensions, 'object'); 167 | t.equal(result.extensions.count, 0); 168 | t.same(result.extensions.id, []); 169 | 170 | t.type(result.projectVersion, 'number'); 171 | t.equal(result.projectVersion, 2); 172 | 173 | t.type(result.isBundle, 'boolean'); 174 | t.equal(result.isBundle, true); 175 | 176 | t.end(); 177 | }); 178 | }); 179 | 180 | test('complex (binary)', t => { 181 | analysis(complexBinary, (err, result) => { 182 | t.ok(typeof err === 'undefined' || err === null); 183 | t.type(result, 'object'); 184 | 185 | t.type(result.scripts, 'object'); 186 | t.equal(result.scripts.count, 6); 187 | 188 | t.type(result.variables, 'object'); 189 | t.equal(result.variables.count, 2); 190 | t.same(result.variables.id, [ 191 | 'global', 192 | 'local' 193 | ]); 194 | 195 | t.type(result.lists, 'object'); 196 | t.equal(result.lists.count, 2); 197 | t.same(result.lists.id, [ 198 | 'globallist', 199 | 'locallist' 200 | ]); 201 | 202 | t.type(result.comments, 'object'); 203 | t.equal(result.comments.count, 0); 204 | 205 | t.type(result.sounds, 'object'); 206 | t.equal(result.sounds.count, 2); 207 | t.same(result.sounds.id, [ 208 | 'pop', 209 | 'meow' 210 | ]); 211 | t.same(result.sounds.hash, [ 212 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 213 | '83c36d806dc92327b9e7049a565c6bff.wav' 214 | ]); 215 | 216 | t.type(result.costumes, 'object'); 217 | t.equal(result.costumes.count, 3); 218 | t.same(result.costumes.id, [ 219 | 'backdrop1', 220 | 'costume1', 221 | 'costume2' 222 | ]); 223 | t.same(result.costumes.hash, [ 224 | '5b465b3b07d39019109d8dc6d6ee6593.svg', 225 | 'f9a1c175dbe2e5dee472858dd30d16bb.svg', 226 | '6e8bd9ae68fdb02b7e1e3df656a75635.svg' 227 | ]); 228 | 229 | t.type(result.backdrops, 'object'); 230 | t.equal(result.backdrops.count, 1); 231 | t.same(result.backdrops.id, [ 232 | 'backdrop1' 233 | ]); 234 | t.same(result.backdrops.hash, [ 235 | '5b465b3b07d39019109d8dc6d6ee6593.svg' 236 | ]); 237 | 238 | t.type(result.sprites, 'object'); 239 | t.equal(result.sprites.count, 1); 240 | 241 | t.type(result.blocks, 'object'); 242 | t.equal(result.blocks.count, 34); 243 | t.equal(result.blocks.unique, 18); 244 | t.same(result.blocks.id, [ 245 | 'whenGreenFlag', 246 | 'doForever', 247 | 'changeGraphicEffect:by:', 248 | 'whenGreenFlag', 249 | 'deleteLine:ofList:', 250 | 'deleteLine:ofList:', 251 | 'doForever', 252 | 'forward:', 253 | 'turnRight:', 254 | 'randomFrom:to:', 255 | 'bounceOffEdge', 256 | 'whenGreenFlag', 257 | 'doForever', 258 | 'setGraphicEffect:to:', 259 | 'xpos', 260 | 'whenGreenFlag', 261 | 'doForever', 262 | 'call', 263 | 'randomFrom:to:', 264 | 'heading', 265 | 'randomFrom:to:', 266 | 'heading', 267 | 'procDef', 268 | 'setVar:to:', 269 | 'getParam', 270 | 'setVar:to:', 271 | 'getParam', 272 | 'append:toList:', 273 | 'getParam', 274 | 'append:toList:', 275 | 'getParam', 276 | 'LEGO WeDo 2.0\u001FwhenTilted', 277 | 'LEGO WeDo 2.0\u001FsetLED', 278 | 'randomFrom:to:' 279 | ]); 280 | t.same(result.blocks.frequency, { 281 | 'LEGO WeDo 2.0\u001FsetLED': 1, 282 | 'LEGO WeDo 2.0\u001FwhenTilted': 1, 283 | 'bounceOffEdge': 1, 284 | 'call': 1, 285 | 'changeGraphicEffect:by:': 1, 286 | 'doForever': 4, 287 | 'deleteLine:ofList:': 2, 288 | 'forward:': 1, 289 | 'getParam': 4, 290 | 'heading': 2, 291 | 'procDef': 1, 292 | 'append:toList:': 2, 293 | 'randomFrom:to:': 4, 294 | 'setGraphicEffect:to:': 1, 295 | 'setVar:to:': 2, 296 | 'turnRight:': 1, 297 | 'whenGreenFlag': 4, 298 | 'xpos': 1 299 | }); 300 | 301 | t.type(result.extensions, 'object'); 302 | t.equal(result.extensions.count, 1); 303 | t.same(result.extensions.id, [ 304 | 'LEGO WeDo 2.0' 305 | ]); 306 | 307 | t.type(result.projectVersion, 'number'); 308 | t.equal(result.projectVersion, 2); 309 | 310 | t.type(result.isBundle, 'boolean'); 311 | t.equal(result.isBundle, true); 312 | 313 | t.end(); 314 | }); 315 | }); 316 | 317 | test('stage with invalid costumes', t => { 318 | const project = JSON.parse(invalidCostumes); 319 | 320 | sb2(project, (err, result) => { 321 | t.ok(typeof err === 'undefined' || err === null); 322 | t.type(result, 'object'); 323 | t.type(result.backdrops, 'object'); 324 | t.equal(result.backdrops.count, 0); 325 | t.same(result.backdrops.id, []); 326 | t.same(result.backdrops.hash, []); 327 | 328 | t.end(); 329 | }); 330 | }); 331 | 332 | test('works with a project where the "info" field is missing', t => { 333 | analysis(missingInfo, (err, result) => { 334 | t.ok(typeof err === 'undefined' || err === null); 335 | t.type(result, 'object'); 336 | t.end(); 337 | }); 338 | }); 339 | -------------------------------------------------------------------------------- /test/unit/sb3.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const test = require('tap').test; 4 | const analysis = require('../../lib/index'); 5 | 6 | const defaultObject = fs.readFileSync( 7 | path.resolve(__dirname, '../fixtures/sb3/default.json') 8 | ); 9 | const defaultBinary = fs.readFileSync( 10 | path.resolve(__dirname, '../fixtures/sb3/default.sb3') 11 | ); 12 | const complexBinary = fs.readFileSync( 13 | path.resolve(__dirname, '../fixtures/sb3/complex.sb3') 14 | ); 15 | 16 | const extensionsBinary = fs.readFileSync( 17 | path.resolve(__dirname, '../fixtures/sb3/extensions.sb3') 18 | ); 19 | 20 | const badExtensions = fs.readFileSync( 21 | path.resolve(__dirname, '../fixtures/sb3/badExtensions.json') 22 | ); 23 | 24 | const primitiveVariableAndListBlocks = fs.readFileSync( 25 | path.resolve(__dirname, '../fixtures/sb3/primitiveVariableAndListBlocks.json') 26 | ); 27 | 28 | const missingVariableField = fs.readFileSync( 29 | path.resolve(__dirname, '../fixtures/sb3/missingVariableField.json') 30 | ); 31 | 32 | const extensionsObject = fs.readFileSync( 33 | path.resolve(__dirname, '../fixtures/sb3/extensionsObject.json') 34 | ); 35 | 36 | test('default (object)', t => { 37 | analysis(defaultObject, (err, result) => { 38 | t.ok(typeof err === 'undefined' || err === null); 39 | t.type(result, 'object'); 40 | 41 | t.type(result.scripts, 'object'); 42 | t.equal(result.scripts.count, 0); 43 | 44 | t.type(result.variables, 'object'); 45 | t.equal(result.variables.count, 1); 46 | t.same(result.variables.id, [ 47 | 'my variable' 48 | ]); 49 | 50 | t.type(result.lists, 'object'); 51 | t.equal(result.lists.count, 0); 52 | t.same(result.lists.id, []); 53 | 54 | t.type(result.comments, 'object'); 55 | t.equal(result.comments.count, 0); 56 | 57 | t.type(result.sounds, 'object'); 58 | t.equal(result.sounds.count, 2); 59 | t.same(result.sounds.id, [ 60 | 'pop', 61 | 'Meow' 62 | ]); 63 | t.same(result.sounds.hash, [ 64 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 65 | '83c36d806dc92327b9e7049a565c6bff.wav' 66 | ]); 67 | 68 | t.type(result.costumes, 'object'); 69 | t.equal(result.costumes.count, 4); 70 | t.same(result.costumes.id, [ 71 | 'backdrop1', 72 | 'costume1', 73 | 'costume2', 74 | 'costume_without_md5ext' 75 | ]); 76 | t.same(result.costumes.hash, [ 77 | 'cd21514d0531fdffb22204e0ec5ed84a.svg', 78 | 'b7853f557e4426412e64bb3da6531a99.svg', 79 | 'e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg', 80 | 'd27716e022fb5f747d7b09fe6eeeca06.svg' 81 | ]); 82 | 83 | t.type(result.backdrops, 'object'); 84 | t.equal(result.backdrops.count, 1); 85 | t.same(result.backdrops.id, [ 86 | 'backdrop1' 87 | ]); 88 | t.same(result.backdrops.hash, [ 89 | 'cd21514d0531fdffb22204e0ec5ed84a.svg' 90 | ]); 91 | 92 | t.type(result.sprites, 'object'); 93 | t.equal(result.sprites.count, 1); 94 | 95 | t.type(result.blocks, 'object'); 96 | t.equal(result.blocks.count, 0); 97 | t.equal(result.blocks.unique, 0); 98 | t.same(result.blocks.id, []); 99 | t.same(result.blocks.frequency, {}); 100 | 101 | t.type(result.extensions, 'object'); 102 | t.equal(result.extensions.count, 0); 103 | t.same(result.extensions.id, []); 104 | 105 | t.type(result.meta, 'object'); 106 | t.same(result.meta, { 107 | semver: '3.0.0', 108 | vm: '0.2.0-prerelease.20181217191056', 109 | agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) ' + 110 | 'Chrome/70.0.3538.110 Safari/537.36', 111 | origin: 'test.scratch.mit.edu' 112 | }); 113 | 114 | t.type(result.projectVersion, 'number'); 115 | t.equal(result.projectVersion, 3); 116 | 117 | t.type(result.isBundle, 'boolean'); 118 | t.equal(result.isBundle, false); 119 | 120 | t.end(); 121 | }); 122 | }); 123 | 124 | test('default (binary)', t => { 125 | analysis(defaultBinary, (err, result) => { 126 | t.ok(typeof err === 'undefined' || err === null); 127 | t.type(result, 'object'); 128 | 129 | t.type(result.scripts, 'object'); 130 | t.equal(result.scripts.count, 0); 131 | 132 | t.type(result.variables, 'object'); 133 | t.equal(result.variables.count, 1); 134 | t.same(result.variables.id, [ 135 | 'my variable' 136 | ]); 137 | 138 | t.type(result.lists, 'object'); 139 | t.equal(result.lists.count, 0); 140 | t.same(result.lists.id, []); 141 | 142 | t.type(result.comments, 'object'); 143 | t.equal(result.comments.count, 0); 144 | 145 | t.type(result.sounds, 'object'); 146 | t.equal(result.sounds.count, 2); 147 | t.same(result.sounds.id, [ 148 | 'pop', 149 | 'Meow' 150 | ]); 151 | t.same(result.sounds.hash, [ 152 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 153 | '83c36d806dc92327b9e7049a565c6bff.wav' 154 | ]); 155 | 156 | t.type(result.costumes, 'object'); 157 | t.equal(result.costumes.count, 4); 158 | t.same(result.costumes.id, [ 159 | 'backdrop1', 160 | 'costume1', 161 | 'costume2', 162 | 'costume_without_md5ext' 163 | ]); 164 | t.same(result.costumes.hash, [ 165 | 'cd21514d0531fdffb22204e0ec5ed84a.svg', 166 | 'b7853f557e4426412e64bb3da6531a99.svg', 167 | 'e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg', 168 | 'd27716e022fb5f747d7b09fe6eeeca06.svg' 169 | ]); 170 | 171 | t.type(result.backdrops, 'object'); 172 | t.equal(result.backdrops.count, 1); 173 | t.same(result.backdrops.id, [ 174 | 'backdrop1' 175 | ]); 176 | t.same(result.backdrops.hash, [ 177 | 'cd21514d0531fdffb22204e0ec5ed84a.svg' 178 | ]); 179 | 180 | t.type(result.sprites, 'object'); 181 | t.equal(result.sprites.count, 1); 182 | 183 | t.type(result.blocks, 'object'); 184 | t.equal(result.blocks.count, 0); 185 | t.equal(result.blocks.unique, 0); 186 | t.same(result.blocks.id, []); 187 | t.same(result.blocks.frequency, {}); 188 | 189 | t.type(result.extensions, 'object'); 190 | t.equal(result.extensions.count, 0); 191 | t.same(result.extensions.id, []); 192 | 193 | t.type(result.meta, 'object'); 194 | t.same(result.meta, { 195 | semver: '3.0.0', 196 | vm: '0.2.0-prerelease.20181217191056', 197 | agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) ' + 198 | 'Chrome/70.0.3538.110 Safari/537.36' 199 | }); 200 | 201 | t.type(result.projectVersion, 'number'); 202 | t.equal(result.projectVersion, 3); 203 | 204 | t.type(result.isBundle, 'boolean'); 205 | t.equal(result.isBundle, true); 206 | 207 | t.end(); 208 | }); 209 | }); 210 | 211 | test('complex (binary)', t => { 212 | analysis(complexBinary, (err, result) => { 213 | t.ok(typeof err === 'undefined' || err === null); 214 | t.type(result, 'object'); 215 | 216 | t.type(result.scripts, 'object'); 217 | t.equal(result.scripts.count, 6); 218 | 219 | t.type(result.variables, 'object'); 220 | t.equal(result.variables.count, 2); 221 | t.same(result.variables.id, [ 222 | 'global', 223 | 'local' 224 | ]); 225 | 226 | t.type(result.lists, 'object'); 227 | t.equal(result.lists.count, 2); 228 | t.same(result.lists.id, [ 229 | 'globallist', 230 | 'locallist' 231 | ]); 232 | 233 | t.type(result.comments, 'object'); 234 | t.equal(result.comments.count, 0); 235 | 236 | t.type(result.sounds, 'object'); 237 | t.equal(result.sounds.count, 2); 238 | t.same(result.sounds.id, [ 239 | 'pop', 240 | 'meow' 241 | ]); 242 | t.same(result.sounds.hash, [ 243 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 244 | '83c36d806dc92327b9e7049a565c6bff.wav' 245 | ]); 246 | 247 | t.type(result.costumes, 'object'); 248 | t.equal(result.costumes.count, 3); 249 | t.same(result.costumes.id, [ 250 | 'backdrop1', 251 | 'costume1', 252 | 'costume2' 253 | ]); 254 | t.same(result.costumes.hash, [ 255 | '7633d36de03d1df75808f581bbccc742.svg', 256 | 'e6bcb4046c157f60c9f5c3bb5f299fce.svg', 257 | '64208764c777be25d34d813dc0b743c7.svg' 258 | ]); 259 | 260 | t.type(result.backdrops, 'object'); 261 | t.equal(result.backdrops.count, 1); 262 | t.same(result.backdrops.id, [ 263 | 'backdrop1' 264 | ]); 265 | t.same(result.backdrops.hash, [ 266 | '7633d36de03d1df75808f581bbccc742.svg' 267 | ]); 268 | 269 | t.type(result.sprites, 'object'); 270 | t.equal(result.sprites.count, 1); 271 | 272 | t.type(result.blocks, 'object'); 273 | t.equal(result.blocks.count, 34); 274 | t.equal(result.blocks.unique, 18); 275 | t.same(result.blocks.id, [ 276 | 'event_whenflagclicked', 277 | 'control_forever', 278 | 'looks_changeeffectby', 279 | 'event_whenflagclicked', 280 | 'data_deleteoflist', 281 | 'data_deleteoflist', 282 | 'control_forever', 283 | 'motion_movesteps', 284 | 'motion_turnright', 285 | 'operator_random', 286 | 'motion_ifonedgebounce', 287 | 'event_whenflagclicked', 288 | 'control_forever', 289 | 'looks_seteffectto', 290 | 'motion_xposition', 291 | 'event_whenflagclicked', 292 | 'control_forever', 293 | 'procedures_call', 294 | 'operator_random', 295 | 'motion_direction', 296 | 'operator_random', 297 | 'motion_direction', 298 | 'procedures_definition', 299 | 'data_setvariableto', 300 | 'argument_reporter_string_number', 301 | 'data_setvariableto', 302 | 'argument_reporter_string_number', 303 | 'data_addtolist', 304 | 'argument_reporter_string_number', 305 | 'data_addtolist', 306 | 'argument_reporter_string_number', 307 | 'wedo2_whenTilted', 308 | 'wedo2_setLightHue', 309 | 'operator_random' 310 | ]); 311 | t.same(result.blocks.frequency, { 312 | argument_reporter_string_number: 4, 313 | control_forever: 4, 314 | data_addtolist: 2, 315 | data_deleteoflist: 2, 316 | data_setvariableto: 2, 317 | event_whenflagclicked: 4, 318 | looks_changeeffectby: 1, 319 | looks_seteffectto: 1, 320 | motion_direction: 2, 321 | motion_ifonedgebounce: 1, 322 | motion_movesteps: 1, 323 | motion_turnright: 1, 324 | motion_xposition: 1, 325 | operator_random: 4, 326 | procedures_call: 1, 327 | procedures_definition: 1, 328 | wedo2_setLightHue: 1, 329 | wedo2_whenTilted: 1 330 | }); 331 | 332 | t.type(result.extensions, 'object'); 333 | t.equal(result.extensions.count, 1); 334 | t.same(result.extensions.id, [ 335 | 'wedo2' 336 | ]); 337 | 338 | t.type(result.projectVersion, 'number'); 339 | t.equal(result.projectVersion, 3); 340 | 341 | t.type(result.isBundle, 'boolean'); 342 | t.equal(result.isBundle, true); 343 | 344 | t.end(); 345 | }); 346 | }); 347 | 348 | test('extensions', t => { 349 | analysis(extensionsBinary, (err, result) => { 350 | t.ok(typeof err === 'undefined' || err === null); 351 | t.type(result, 'object'); 352 | 353 | t.type(result.extensions, 'object'); 354 | t.equal(result.extensions.count, 2); 355 | t.same(result.extensions.id, [ 356 | 'translate', 357 | 'text2speech' 358 | ]); 359 | 360 | t.end(); 361 | }); 362 | }); 363 | 364 | test('regression test IBE-198, a bad list does not break library', t => { 365 | analysis(badExtensions, (err, result) => { 366 | t.ok(typeof err === 'undefined' || err === null); 367 | t.type(result, 'object'); 368 | 369 | t.type(result.scripts, 'object'); 370 | t.equal(result.scripts.count, 0); 371 | 372 | t.type(result.variables, 'object'); 373 | t.equal(result.variables.count, 1); 374 | t.same(result.variables.id, [ 375 | 'my variable' 376 | ]); 377 | 378 | t.type(result.lists, 'object'); 379 | t.equal(result.lists.count, 0); 380 | t.same(result.lists.id, []); 381 | 382 | t.type(result.comments, 'object'); 383 | t.equal(result.comments.count, 0); 384 | 385 | t.type(result.sounds, 'object'); 386 | t.equal(result.sounds.count, 2); 387 | t.same(result.sounds.id, [ 388 | 'pop', 389 | 'Meow' 390 | ]); 391 | t.same(result.sounds.hash, [ 392 | '83a9787d4cb6f3b7632b4ddfebf74367.wav', 393 | '83c36d806dc92327b9e7049a565c6bff.wav' 394 | ]); 395 | 396 | t.type(result.costumes, 'object'); 397 | t.equal(result.costumes.count, 3); 398 | t.same(result.costumes.id, [ 399 | 'backdrop1', 400 | 'costume1', 401 | 'costume2' 402 | ]); 403 | t.same(result.costumes.hash, [ 404 | 'cd21514d0531fdffb22204e0ec5ed84a.svg', 405 | 'b7853f557e4426412e64bb3da6531a99.svg', 406 | 'e6ddc55a6ddd9cc9d84fe0b4c21e016f.svg' 407 | ]); 408 | 409 | t.type(result.backdrops, 'object'); 410 | t.equal(result.backdrops.count, 1); 411 | t.same(result.backdrops.id, [ 412 | 'backdrop1' 413 | ]); 414 | t.same(result.backdrops.hash, [ 415 | 'cd21514d0531fdffb22204e0ec5ed84a.svg' 416 | ]); 417 | 418 | t.type(result.sprites, 'object'); 419 | t.equal(result.sprites.count, 1); 420 | 421 | t.type(result.blocks, 'object'); 422 | t.equal(result.blocks.count, 0); 423 | t.equal(result.blocks.unique, 0); 424 | t.same(result.blocks.id, []); 425 | t.same(result.blocks.frequency, {}); 426 | 427 | t.type(result.extensions, 'object'); 428 | t.equal(result.extensions.count, 0); 429 | t.same(result.extensions.id, []); 430 | 431 | t.type(result.meta, 'object'); 432 | t.same(result.meta, { 433 | semver: '3.0.0', 434 | vm: '0.2.0-prerelease.20181217191056', 435 | agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) ' + 436 | 'Chrome/70.0.3538.110 Safari/537.36', 437 | origin: 'test.scratch.mit.edu' 438 | }); 439 | 440 | t.type(result.projectVersion, 'number'); 441 | t.equal(result.projectVersion, 3); 442 | 443 | t.type(result.isBundle, 'boolean'); 444 | t.equal(result.isBundle, false); 445 | 446 | t.end(); 447 | }); 448 | }); 449 | 450 | test('correctly handling primitve reporter blocks: list and variable', t => { 451 | analysis(primitiveVariableAndListBlocks, (err, result) => { 452 | t.ok(typeof err === 'undefined' || err === null); 453 | t.type(result, 'object'); 454 | 455 | t.type(result.variables, 'object'); 456 | t.equal(result.variables.count, 1); 457 | t.same(result.variables.id, [ 458 | 'my_variable' 459 | ]); 460 | 461 | t.type(result.lists, 'object'); 462 | t.equal(result.lists.count, 1); 463 | t.same(result.lists.id, [ 464 | 'my_list' 465 | ]); 466 | 467 | t.type(result.blocks, 'object'); 468 | t.equal(result.blocks.count, 3); 469 | t.equal(result.blocks.unique, 3); 470 | t.same(result.blocks.id, [ 471 | 'data_listcontents', 472 | 'motion_changexby', 473 | 'data_variable' 474 | ]); 475 | t.same(result.blocks.frequency, { 476 | data_listcontents: 1, 477 | motion_changexby: 1, 478 | data_variable: 1 479 | }); 480 | t.end(); 481 | }); 482 | }); 483 | 484 | test('missing VARIABLE field in a block does not break the library', t => { 485 | analysis(missingVariableField, (err, result) => { 486 | t.ok(typeof err === 'undefined' || err === null); 487 | t.type(result, 'object'); 488 | 489 | t.end(); 490 | }); 491 | }); 492 | 493 | test('works with a project where "extensions" is an object', t => { 494 | analysis(extensionsObject, (err, result) => { 495 | t.ok(typeof err === 'undefined' || err === null); 496 | t.type(result, 'object'); 497 | 498 | t.type(result.extensions, 'object'); 499 | t.equal(result.extensions.count, 0); 500 | t.same(result.extensions.id, []); 501 | t.end(); 502 | }); 503 | }); 504 | -------------------------------------------------------------------------------- /test/unit/spec.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | const analysis = require('../../lib/index'); 3 | 4 | test('spec', t => { 5 | t.type(analysis, 'function'); 6 | t.end(); 7 | }); 8 | -------------------------------------------------------------------------------- /test/unit/utility.js: -------------------------------------------------------------------------------- 1 | const test = require('tap').test; 2 | const utility = require('../../lib/utility'); 3 | 4 | test('spec', t => { 5 | t.type(utility, 'function'); 6 | t.type(utility.frequency, 'function'); 7 | t.end(); 8 | }); 9 | 10 | test('frequency', t => { 11 | const input = ['foo', 'foo', 'foo', 'bar', 'bar', 'baz']; 12 | const result = utility.frequency(input); 13 | t.same(result, { 14 | foo: 3, 15 | bar: 2, 16 | baz: 1 17 | }); 18 | t.end(); 19 | }); 20 | --------------------------------------------------------------------------------