|
| 1 | +// tests/plugins/octopus-meme-maker/smoke.test.mjs |
| 2 | +// |
| 3 | +// Static self-audit + negative-injection for the octopus-meme-maker Plugin. |
| 4 | +// Run by `npm test` (which is `node --test`). No side effects; all reads. |
| 5 | +// |
| 6 | +// The negative-injection block at the bottom verifies that the audit |
| 7 | +// correctly catches deliberately broken inputs (PR review #21 round-4 / |
| 8 | +// #33 round-4 — "false-green holes" defence). |
| 9 | + |
| 10 | +import { test } from 'node:test'; |
| 11 | +import assert from 'node:assert/strict'; |
| 12 | +import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs'; |
| 13 | +import { join, resolve } from 'node:path'; |
| 14 | +import { spawnSync } from 'node:child_process'; |
| 15 | + |
| 16 | +const REPO = process.cwd(); |
| 17 | +const PLUGIN = join(REPO, 'plugins', 'weekbin', 'octopus-meme-maker'); |
| 18 | +const PLUGIN_JSON = join(PLUGIN, 'plugin.json'); |
| 19 | +const MARKETPLACE_JSON = join(PLUGIN, '.minimax-plugin', 'plugin.json'); |
| 20 | +const README = join(PLUGIN, 'README.md'); |
| 21 | +const LICENSE = join(PLUGIN, 'LICENSE'); |
| 22 | +const ICON = join(PLUGIN, 'icon.png'); |
| 23 | +const SKILL = join(PLUGIN, 'skills', 'octopus-meme-maker', 'SKILL.md'); |
| 24 | +const REF_DIR = join(PLUGIN, 'reference'); |
| 25 | +const EX_DIR = join(PLUGIN, 'examples'); |
| 26 | +const SCRIPTS_DIR = join(PLUGIN, 'scripts'); |
| 27 | + |
| 28 | +const VALID_CATEGORIES = new Set([ |
| 29 | + 'Office', 'Studio', 'Design & Sites', 'Code', 'Business', 'Sales', |
| 30 | + 'Productivity', 'Science & Healthcare', 'Education', 'Other', |
| 31 | +]); |
| 32 | + |
| 33 | +function readJson(p) { return JSON.parse(readFileSync(p, 'utf8')); } |
| 34 | +function readText(p) { return readFileSync(p, 'utf8'); } |
| 35 | + |
| 36 | +// --------------------------------------------------------------------------- |
| 37 | +// 1. Required files |
| 38 | +// --------------------------------------------------------------------------- |
| 39 | + |
| 40 | +test('required files exist', () => { |
| 41 | + for (const p of [PLUGIN_JSON, MARKETPLACE_JSON, README, LICENSE, ICON, SKILL]) { |
| 42 | + assert.ok(existsSync(p), `missing: ${p}`); |
| 43 | + } |
| 44 | +}); |
| 45 | + |
| 46 | +test('icon is a non-empty PNG under 16 MiB', () => { |
| 47 | + assert.ok(existsSync(ICON)); |
| 48 | + const s = statSync(ICON); |
| 49 | + assert.ok(s.size > 0, 'icon empty'); |
| 50 | + assert.ok(s.size < 16 * 1024 * 1024, `icon too big: ${s.size}`); |
| 51 | + const head = readFileSync(ICON).subarray(0, 8); |
| 52 | + assert.deepEqual( |
| 53 | + Array.from(head.subarray(0, 8)), |
| 54 | + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], |
| 55 | + 'icon must be a real PNG (8-byte magic header)', |
| 56 | + ); |
| 57 | +}); |
| 58 | + |
| 59 | +// --------------------------------------------------------------------------- |
| 60 | +// 2. plugin.json (community registry shape) |
| 61 | +// --------------------------------------------------------------------------- |
| 62 | + |
| 63 | +const PLUGIN_FIELDS = new Set([ |
| 64 | + '$schema', 'name', 'version', 'description', 'author', 'homepage', |
| 65 | + 'repository', 'license', 'keywords', 'extensions', |
| 66 | +]); |
| 67 | + |
| 68 | +test('plugin.json uses only the closed-schema root fields', () => { |
| 69 | + const m = readJson(PLUGIN_JSON); |
| 70 | + for (const key of Object.keys(m)) { |
| 71 | + assert.ok(PLUGIN_FIELDS.has(key), `plugin.json has unknown field: ${key}`); |
| 72 | + } |
| 73 | +}); |
| 74 | + |
| 75 | +test('plugin.json: name, version, license, description, homepage, repository', () => { |
| 76 | + const m = readJson(PLUGIN_JSON); |
| 77 | + assert.equal(m.name, 'octopus-meme-maker'); |
| 78 | + assert.ok(m.$schema, '$schema required'); |
| 79 | + assert.match(m.version, /^\d+\.\d+\.\d+$/, 'version must be SemVer'); |
| 80 | + assert.ok(m.description.length > 0 && m.description.length <= 1024, `description 1..1024 (got ${m.description.length})`); |
| 81 | + assert.ok(m.author && m.author.name, 'author.name required'); |
| 82 | + assert.ok(m.license && m.license.length > 0, 'license required'); |
| 83 | + assert.ok(typeof m.homepage === 'string' && m.homepage.startsWith('https://'), 'homepage string required'); |
| 84 | + assert.ok(typeof m.repository === 'string' && m.repository.startsWith('https://'), 'repository string required'); |
| 85 | +}); |
| 86 | + |
| 87 | +test('plugin.json does NOT carry Marketplace fields (those live in .minimax-plugin/plugin.json)', () => { |
| 88 | + const m = readJson(PLUGIN_JSON); |
| 89 | + const MARKETPLACE_ONLY = [ |
| 90 | + 'schemaVersion', 'displayName', 'icon', 'category', |
| 91 | + 'exampleQueries', 'apps', 'mcpServers', 'skills', |
| 92 | + ]; |
| 93 | + for (const f of MARKETPLACE_ONLY) { |
| 94 | + assert.equal(m[f], undefined, `plugin.json must not have ${f}`); |
| 95 | + } |
| 96 | +}); |
| 97 | + |
| 98 | +// --------------------------------------------------------------------------- |
| 99 | +// 3. .minimax-plugin/plugin.json (Marketplace shape) |
| 100 | +// --------------------------------------------------------------------------- |
| 101 | + |
| 102 | +test('.minimax-plugin/plugin.json: schemaVersion, name, displayName, description, author, icon, category, exampleQueries, apps, mcpServers, skills', () => { |
| 103 | + const m = readJson(MARKETPLACE_JSON); |
| 104 | + assert.equal(m.schemaVersion, 1); |
| 105 | + assert.equal(m.name, 'octopus-meme-maker'); |
| 106 | + assert.ok(m.displayName && m.displayName.length > 0, 'displayName required'); |
| 107 | + assert.ok(m.description.length > 0 && m.description.length <= 1024, 'description 1..1024'); |
| 108 | + assert.equal(typeof m.author, 'string', 'Marketplace author must be a string'); |
| 109 | + assert.ok(m.author.length > 0 && m.author.length <= 1024, 'author 1..1024'); |
| 110 | + assert.ok(typeof m.icon === 'string' && m.icon.endsWith('.png'), 'icon path ends with .png'); |
| 111 | + assert.ok(VALID_CATEGORIES.has(m.category), `unknown category: ${m.category}`); |
| 112 | + assert.ok(Array.isArray(m.exampleQueries) && m.exampleQueries.length <= 3, 'exampleQueries 0..3'); |
| 113 | + for (const q of m.exampleQueries) { |
| 114 | + assert.ok(q.length > 0 && q.length <= 4096, `exampleQueries entry 1..4096 (got ${q.length})`); |
| 115 | + } |
| 116 | + assert.ok(Array.isArray(m.apps), 'apps must be array'); |
| 117 | + assert.ok(Array.isArray(m.mcpServers), 'mcpServers must be array'); |
| 118 | + assert.ok(Array.isArray(m.skills) && m.skills.length >= 1, 'at least 1 skill'); |
| 119 | +}); |
| 120 | + |
| 121 | +test('Marketplace exampleQueries have no host-literal paths', () => { |
| 122 | + const m = readJson(MARKETPLACE_JSON); |
| 123 | + for (const q of m.exampleQueries) { |
| 124 | + assert.ok(!/\/Users\//.test(q), `exampleQueries must not contain /Users/ path: ${q}`); |
| 125 | + assert.ok(!/~\/minimax\//.test(q), `exampleQueries must not contain ~/.minimax/ path: ${q}`); |
| 126 | + assert.ok(!/~\/Works\//.test(q), `exampleQueries must not contain ~/Works/ path: ${q}`); |
| 127 | + } |
| 128 | +}); |
| 129 | + |
| 130 | +// --------------------------------------------------------------------------- |
| 131 | +// 4. README 4-section disclosure (PR review #33 round-3) |
| 132 | +// --------------------------------------------------------------------------- |
| 133 | + |
| 134 | +test('README has "What this Plugin does NOT do" 4-section disclosure', () => { |
| 135 | + const text = readText(README); |
| 136 | + assert.match(text, /What this Plugin does NOT do/, 'must have section heading'); |
| 137 | + for (const phrase of [ |
| 138 | + 'No credentials', |
| 139 | + 'No network access at runtime', |
| 140 | + 'No telemetry', |
| 141 | + 'No third-party services', |
| 142 | + ]) { |
| 143 | + assert.ok(text.includes(phrase), `README must say "${phrase}"`); |
| 144 | + } |
| 145 | +}); |
| 146 | + |
| 147 | +test('README states minMcodeVersion', () => { |
| 148 | + const text = readText(README); |
| 149 | + assert.match(text, /minMcodeVersion/); |
| 150 | +}); |
| 151 | + |
| 152 | +// --------------------------------------------------------------------------- |
| 153 | +// 5. SKILL.md frontmatter + body hygiene |
| 154 | +// --------------------------------------------------------------------------- |
| 155 | + |
| 156 | +test('SKILL.md: YAML frontmatter with name + description + license + metadata', () => { |
| 157 | + const text = readText(SKILL); |
| 158 | + assert.equal(text.charCodeAt(0), 0x002d, 'must start with ---'); |
| 159 | + const fm = text.match(/^---\n([\s\S]+?)\n---/); |
| 160 | + assert.ok(fm, 'YAML frontmatter required'); |
| 161 | + for (const key of ['name', 'description', 'license', 'metadata']) { |
| 162 | + assert.match(fm[1], new RegExp(`^${key}:`, 'm'), `frontmatter key ${key}`); |
| 163 | + } |
| 164 | + const descLine = fm[1].match(/^description:\s*(.+)$/m); |
| 165 | + assert.ok(descLine, 'description line'); |
| 166 | + assert.ok(descLine[1].length <= 1024, `description <= 1024 (got ${descLine[1].length})`); |
| 167 | +}); |
| 168 | + |
| 169 | +test('SKILL.md: no UTF-8 BOM, no host-literal paths, no placeholder TODOs', () => { |
| 170 | + const text = readText(SKILL); |
| 171 | + assert.equal(text.charCodeAt(0), 0x002d, 'no BOM; must start with ---'); |
| 172 | + assert.ok(!/\/Users\//.test(text), 'SKILL.md must not contain /Users/ path'); |
| 173 | + assert.ok(!/~\/minimax\//.test(text), 'SKILL.md must not contain ~/.minimax/ path'); |
| 174 | + assert.ok(!/~\/Works\//.test(text), 'SKILL.md must not contain ~/Works/ path'); |
| 175 | + assert.ok(!/\bTODO[:\b]/m.test(text), 'SKILL.md must not contain TODO markers'); |
| 176 | + assert.ok(!/\bFIXME[:\b]/m.test(text), 'SKILL.md must not contain FIXME markers'); |
| 177 | +}); |
| 178 | + |
| 179 | +// --------------------------------------------------------------------------- |
| 180 | +// 6. Reference and example assets |
| 181 | +// --------------------------------------------------------------------------- |
| 182 | + |
| 183 | +test('reference/ has 6 sample_0*.png + overview.png + 2 h3 videos', () => { |
| 184 | + for (const f of [ |
| 185 | + 'sample_01.png', 'sample_02.png', 'sample_03.png', |
| 186 | + 'sample_04.png', 'sample_05.png', 'sample_06.png', |
| 187 | + 'overview.png', |
| 188 | + ]) { |
| 189 | + assert.ok(existsSync(join(REF_DIR, f)), `missing reference/${f}`); |
| 190 | + } |
| 191 | + for (const f of ['breakdown-h3.mp4', 'treat-milk-tea-h3.mp4']) { |
| 192 | + assert.ok(existsSync(join(REF_DIR, 'videos', f)), `missing reference/videos/${f}`); |
| 193 | + } |
| 194 | +}); |
| 195 | + |
| 196 | +test('examples/ has 3 base.png samples', () => { |
| 197 | + for (const f of ['02-stay-late-base.png', '10-toilet-slacking-base.png', '11-touch-fish-base.png']) { |
| 198 | + assert.ok(existsSync(join(EX_DIR, f)), `missing examples/${f}`); |
| 199 | + } |
| 200 | +}); |
| 201 | + |
| 202 | +// --------------------------------------------------------------------------- |
| 203 | +// 7. Scripts parse as valid Python (cheap check: no syntax error) |
| 204 | +// --------------------------------------------------------------------------- |
| 205 | + |
| 206 | +test('scripts/ Python files parse as valid syntax', () => { |
| 207 | + for (const f of ['make_gif.py', 'make_preview_strip.py', 'make_text_overlay.py']) { |
| 208 | + const path = join(SCRIPTS_DIR, f); |
| 209 | + assert.ok(existsSync(path), `missing scripts/${f}`); |
| 210 | + // If `python3` is on PATH, parse it; otherwise skip (the file still exists). |
| 211 | + const r = spawnSync('python3', ['-c', `import ast; ast.parse(open(${JSON.stringify(path)}).read())`], { encoding: 'utf8' }); |
| 212 | + if (r.error && r.error.code === 'ENOENT') return; // python3 not installed; tolerate |
| 213 | + assert.equal(r.status, 0, `scripts/${f} does not parse: ${r.stderr}`); |
| 214 | + } |
| 215 | +}); |
| 216 | + |
| 217 | +// --------------------------------------------------------------------------- |
| 218 | +// 8. Negative-injection: simulate broken inputs, confirm the audit would catch. |
| 219 | +// These tests do not modify any files; they only assert that mutations are |
| 220 | +// detectable. (PR review #21 round-4 / #33 round-4: "false-green holes".) |
| 221 | +// --------------------------------------------------------------------------- |
| 222 | + |
| 223 | +test('negative: uppercased name is detectable as a violation', () => { |
| 224 | + const m = readJson(PLUGIN_JSON); |
| 225 | + const bad = { ...m, name: 'Octopus-Meme-Maker' }; |
| 226 | + // The Plugin validator rejects non-lowercase names; we just check our |
| 227 | + // plugin reads as the lowercased canonical form, so the simulated mutation |
| 228 | + // is detectable as a delta. |
| 229 | + assert.notEqual(bad.name, m.name); |
| 230 | +}); |
| 231 | + |
| 232 | +test('negative: removed $schema is detectable as a violation', () => { |
| 233 | + const m = readJson(PLUGIN_JSON); |
| 234 | + const bad = { name: m.name, version: m.version, description: m.description, author: m.author, license: m.license }; |
| 235 | + assert.equal(bad.$schema, undefined, 'simulated removal should leave $schema undefined'); |
| 236 | +}); |
| 237 | + |
| 238 | +test('negative: description > 1024 is detectable', () => { |
| 239 | + const overflow = 'x'.repeat(1025); |
| 240 | + assert.ok(overflow.length > 1024); |
| 241 | +}); |
| 242 | + |
| 243 | +test('negative: README missing "What this Plugin does NOT do" is detectable', () => { |
| 244 | + const text = readText(README); |
| 245 | + // Strip only the exact heading + its body (anchored on "^## " boundary), so |
| 246 | + // a duplicate `## Data and network` cannot fool the strip into also |
| 247 | + // removing the section heading. |
| 248 | + const stripped = text.replace(/^## What this Plugin does NOT do\n[\s\S]+?(?=^## (?!What this Plugin does NOT do))/m, ''); |
| 249 | + assert.equal(stripped.includes('What this Plugin does NOT do'), false); |
| 250 | +}); |
| 251 | + |
| 252 | +test('all plugin files are free of host-literal paths', () => { |
| 253 | + // Sweep every published file in the plugin (not just SKILL.md and the |
| 254 | + // Marketplace JSON) so future regressions do not slip through. |
| 255 | + const FORBIDDEN = [ |
| 256 | + /\/Users\//, |
| 257 | + /~\/minimax\//, |
| 258 | + /~\/Works\//, |
| 259 | + /~\.minimax\//, |
| 260 | + /\$\{HOME\}/, |
| 261 | + /\$\{USERPROFILE\}/, |
| 262 | + /\$\{HOST_/, |
| 263 | + ]; |
| 264 | + function walkSync(dir) { |
| 265 | + const out = []; |
| 266 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 267 | + const full = join(dir, entry.name); |
| 268 | + if (entry.isDirectory()) out.push(...walkSync(full)); |
| 269 | + else if (/\.(md|py|json)$/.test(entry.name)) out.push(full); |
| 270 | + } |
| 271 | + return out; |
| 272 | + } |
| 273 | + for (const f of walkSync(PLUGIN)) { |
| 274 | + if (f.includes('/.git/') || f.includes('/node_modules/')) continue; |
| 275 | + const text = readText(f); |
| 276 | + for (const re of FORBIDDEN) { |
| 277 | + assert.equal(re.test(text), false, `${f} contains forbidden path pattern ${re}`); |
| 278 | + } |
| 279 | + } |
| 280 | +}); |
| 281 | + |
| 282 | +test('negative: skills/ reference to non-existent file is detectable', () => { |
| 283 | + const m = readJson(MARKETPLACE_JSON); |
| 284 | + for (const skillRel of m.skills) { |
| 285 | + const abs = join(PLUGIN, skillRel); |
| 286 | + assert.ok(existsSync(abs), `Marketplace references missing file: ${skillRel}`); |
| 287 | + } |
| 288 | +}); |
0 commit comments