|
| 1 | +/** |
| 2 | + * Smoke test — opens the app in headless Chromium via Puppeteer, |
| 3 | + * checks for JS errors, verifies key screens render. |
| 4 | + * |
| 5 | + * Usage: node tests/smoke.js |
| 6 | + * Starts its own HTTP server on port 8199, tears it down when done. |
| 7 | + */ |
| 8 | + |
| 9 | +var http = require('http'); |
| 10 | +var fs = require('fs'); |
| 11 | +var path = require('path'); |
| 12 | +var puppeteer = require('puppeteer'); |
| 13 | + |
| 14 | +// ---------- tiny static server ---------- |
| 15 | +var APP_DIR = path.join(__dirname, '..', 'app'); |
| 16 | +var PORT = 8199; |
| 17 | +var MIME = { |
| 18 | + '.html': 'text/html', |
| 19 | + '.js': 'application/javascript', |
| 20 | + '.css': 'text/css', |
| 21 | + '.json': 'application/json', |
| 22 | + '.png': 'image/png', |
| 23 | + '.svg': 'image/svg+xml' |
| 24 | +}; |
| 25 | + |
| 26 | +function startServer() { |
| 27 | + return new Promise(function (resolve) { |
| 28 | + var srv = http.createServer(function (req, res) { |
| 29 | + var url = req.url.split('?')[0]; |
| 30 | + if (url === '/') url = '/index.html'; |
| 31 | + var filePath = path.join(APP_DIR, url); |
| 32 | + var ext = path.extname(filePath); |
| 33 | + fs.readFile(filePath, function (err, data) { |
| 34 | + if (err) { |
| 35 | + res.writeHead(404); |
| 36 | + res.end('Not found'); |
| 37 | + return; |
| 38 | + } |
| 39 | + res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' }); |
| 40 | + res.end(data); |
| 41 | + }); |
| 42 | + }); |
| 43 | + srv.listen(PORT, function () { |
| 44 | + console.log('Server listening on port ' + PORT); |
| 45 | + resolve(srv); |
| 46 | + }); |
| 47 | + }); |
| 48 | +} |
| 49 | + |
| 50 | +// ---------- test runner ---------- |
| 51 | +var errors = []; |
| 52 | +var warnings = []; |
| 53 | +var passed = 0; |
| 54 | +var failed = 0; |
| 55 | + |
| 56 | +function assert(label, ok, detail) { |
| 57 | + if (ok) { |
| 58 | + passed++; |
| 59 | + console.log(' PASS: ' + label); |
| 60 | + } else { |
| 61 | + failed++; |
| 62 | + console.log(' FAIL: ' + label + (detail ? ' — ' + detail : '')); |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +async function runTests() { |
| 67 | + var srv = await startServer(); |
| 68 | + var browser; |
| 69 | + try { |
| 70 | + browser = await puppeteer.launch({ |
| 71 | + headless: 'new', |
| 72 | + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] |
| 73 | + }); |
| 74 | + var page = await browser.newPage(); |
| 75 | + |
| 76 | + // Collect console errors |
| 77 | + page.on('console', function (msg) { |
| 78 | + if (msg.type() === 'error') { |
| 79 | + errors.push(msg.text()); |
| 80 | + } else if (msg.type() === 'warning') { |
| 81 | + warnings.push(msg.text()); |
| 82 | + } |
| 83 | + }); |
| 84 | + |
| 85 | + // Collect uncaught exceptions |
| 86 | + page.on('pageerror', function (err) { |
| 87 | + errors.push('PAGE ERROR: ' + err.message); |
| 88 | + }); |
| 89 | + |
| 90 | + // ---------- 1. Page loads without crash ---------- |
| 91 | + console.log('\n--- Test: page load ---'); |
| 92 | + await page.goto('http://localhost:' + PORT + '/', { |
| 93 | + waitUntil: 'networkidle0', |
| 94 | + timeout: 15000 |
| 95 | + }); |
| 96 | + assert('Page loaded', true); |
| 97 | + |
| 98 | + // ---------- 2. Key globals exist ---------- |
| 99 | + console.log('\n--- Test: global modules ---'); |
| 100 | + var globals = [ |
| 101 | + 'VizMagicConfig', 'Helpers', 'StateEngine', 'BlockProcessor', |
| 102 | + 'CombatSystem', 'ItemSystem', 'CharacterSystem', 'CraftingSystem', |
| 103 | + 'GuildSystem', 'DuelStateManager', 'VMProtocol', 'GameCreatures', |
| 104 | + 'GameSpells', 'GameRecipes', 'GameRegions', 'WorldBoss' |
| 105 | + ]; |
| 106 | + for (var i = 0; i < globals.length; i++) { |
| 107 | + var exists = await page.evaluate('typeof ' + globals[i] + ' !== "undefined"'); |
| 108 | + assert(globals[i] + ' defined', exists); |
| 109 | + } |
| 110 | + |
| 111 | + // ---------- 3. Screen sections exist in DOM ---------- |
| 112 | + console.log('\n--- Test: screen sections ---'); |
| 113 | + var screens = [ |
| 114 | + 'screen-landing', 'screen-login', 'screen-home', |
| 115 | + 'screen-hunt', 'screen-character', 'screen-inventory', |
| 116 | + 'screen-crafting', 'screen-arena', 'screen-guild', |
| 117 | + 'screen-chronicle', 'screen-map', 'screen-world-boss' |
| 118 | + ]; |
| 119 | + for (var si = 0; si < screens.length; si++) { |
| 120 | + var found = await page.evaluate( |
| 121 | + '!!document.getElementById("' + screens[si] + '")' |
| 122 | + ); |
| 123 | + assert('Section #' + screens[si], found); |
| 124 | + } |
| 125 | + |
| 126 | + // ---------- 4. StateEngine initializes ---------- |
| 127 | + console.log('\n--- Test: StateEngine ---'); |
| 128 | + var seState = await page.evaluate(function () { |
| 129 | + if (typeof StateEngine === 'undefined') return null; |
| 130 | + var s = StateEngine.getState(); |
| 131 | + return { |
| 132 | + hasCharacters: typeof s.characters === 'object', |
| 133 | + hasInventories: typeof s.inventories === 'object', |
| 134 | + hasGuilds: typeof s.guilds === 'object', |
| 135 | + hasGuildListings: Array.isArray(s.guildListings), |
| 136 | + hasSocial: typeof s.social === 'object' |
| 137 | + }; |
| 138 | + }); |
| 139 | + if (seState) { |
| 140 | + assert('state.characters is object', seState.hasCharacters); |
| 141 | + assert('state.inventories is object', seState.hasInventories); |
| 142 | + assert('state.guilds is object', seState.hasGuilds); |
| 143 | + assert('state.guildListings is array', seState.hasGuildListings); |
| 144 | + assert('state.social is object', seState.hasSocial); |
| 145 | + } else { |
| 146 | + assert('StateEngine.getState() returned', false, 'null'); |
| 147 | + } |
| 148 | + |
| 149 | + // ---------- 5. ItemSystem idempotency (the fix we made) ---------- |
| 150 | + console.log('\n--- Test: hunt idempotency ---'); |
| 151 | + var idempotencyOk = await page.evaluate(function () { |
| 152 | + // Create a fake character and run two hunts on same block |
| 153 | + var state = StateEngine.getState(); |
| 154 | + var testAcct = '__smoke_test_account__'; |
| 155 | + state.characters[testAcct] = CharacterSystem.createCharacter(testAcct, 'TestMage', 'embercaster'); |
| 156 | + state.inventories[testAcct] = []; |
| 157 | + |
| 158 | + // Simulate processHuntResult (optimistic path) |
| 159 | + var allCreatures = GameCreatures.getAll(); |
| 160 | + var creatureId = Object.keys(allCreatures)[0]; |
| 161 | + var creature = allCreatures[creatureId]; |
| 162 | + var spells = GameSpells.getSpellsForClass('embercaster'); |
| 163 | + var spell = spells[0]; |
| 164 | + if (!creature || !spell) return { error: 'no creature/spell data' }; |
| 165 | + |
| 166 | + var result = StateEngine.processHuntResult( |
| 167 | + testAcct, creature.id, spell.id, |
| 168 | + 'aabbccdd', 99999, 10000 |
| 169 | + ); |
| 170 | + |
| 171 | + var countAfterOptimistic = state.inventories[testAcct].length; |
| 172 | + |
| 173 | + // Now simulate block replay — _handleHunt via processBlock |
| 174 | + var fakeBlock = { |
| 175 | + blockNum: 99999, |
| 176 | + blockHash: 'aabbccdd', |
| 177 | + vmActions: [{ |
| 178 | + sender: testAcct, |
| 179 | + action: { type: 'hunt', data: { creature: creature.id, spell: spell.id } } |
| 180 | + }], |
| 181 | + voicePosts: [], |
| 182 | + awards: [] |
| 183 | + }; |
| 184 | + StateEngine.processBlock(fakeBlock); |
| 185 | + |
| 186 | + var countAfterReplay = state.inventories[testAcct].length; |
| 187 | + |
| 188 | + // Cleanup |
| 189 | + delete state.characters[testAcct]; |
| 190 | + delete state.inventories[testAcct]; |
| 191 | + |
| 192 | + return { |
| 193 | + countAfterOptimistic: countAfterOptimistic, |
| 194 | + countAfterReplay: countAfterReplay, |
| 195 | + noDuplication: countAfterOptimistic === countAfterReplay |
| 196 | + }; |
| 197 | + }); |
| 198 | + if (idempotencyOk && !idempotencyOk.error) { |
| 199 | + assert('Hunt loot not duplicated on replay', |
| 200 | + idempotencyOk.noDuplication, |
| 201 | + 'after optimistic: ' + idempotencyOk.countAfterOptimistic + |
| 202 | + ', after replay: ' + idempotencyOk.countAfterReplay); |
| 203 | + } else { |
| 204 | + assert('Hunt idempotency test ran', false, idempotencyOk ? idempotencyOk.error : 'null'); |
| 205 | + } |
| 206 | + |
| 207 | + // ---------- 6. Guild listing accepted without membership ---------- |
| 208 | + console.log('\n--- Test: guild listing acceptance ---'); |
| 209 | + var guildListingOk = await page.evaluate(function () { |
| 210 | + var state = StateEngine.getState(); |
| 211 | + var before = (state.guildListings || []).length; |
| 212 | + |
| 213 | + // Broadcast a guild.listing from an unknown sender for an unknown guild |
| 214 | + var fakeBlock = { |
| 215 | + blockNum: 88888, |
| 216 | + blockHash: 'deadbeef', |
| 217 | + vmActions: [{ |
| 218 | + sender: '__unknown_sender__', |
| 219 | + action: { |
| 220 | + type: 'guild.listing', |
| 221 | + data: { guild_id: '__test_guild__', created_block: 10000 } |
| 222 | + } |
| 223 | + }], |
| 224 | + voicePosts: [], |
| 225 | + awards: [] |
| 226 | + }; |
| 227 | + StateEngine.processBlock(fakeBlock); |
| 228 | + |
| 229 | + var after = (state.guildListings || []).length; |
| 230 | + |
| 231 | + // Cleanup: remove test listing |
| 232 | + for (var i = state.guildListings.length - 1; i >= 0; i--) { |
| 233 | + if (state.guildListings[i].guild_id === '__test_guild__') { |
| 234 | + state.guildListings.splice(i, 1); |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + return { before: before, after: after, accepted: after === before + 1 }; |
| 239 | + }); |
| 240 | + assert('Guild listing accepted without membership check', |
| 241 | + guildListingOk && guildListingOk.accepted, |
| 242 | + guildListingOk ? 'before: ' + guildListingOk.before + ', after: ' + guildListingOk.after : 'null'); |
| 243 | + |
| 244 | + // ---------- 7. No JS errors ---------- |
| 245 | + console.log('\n--- Test: console errors ---'); |
| 246 | + // Filter out expected errors (blockchain connection fails in test env) |
| 247 | + var criticalErrors = errors.filter(function (e) { |
| 248 | + return e.indexOf('WebSocket') === -1 && |
| 249 | + e.indexOf('ERR_CONNECTION_REFUSED') === -1 && |
| 250 | + e.indexOf('net::') === -1 && |
| 251 | + e.indexOf('Failed to fetch') === -1; |
| 252 | + }); |
| 253 | + assert('No critical JS errors', criticalErrors.length === 0, |
| 254 | + criticalErrors.length + ' errors: ' + criticalErrors.slice(0, 3).join('; ')); |
| 255 | + |
| 256 | + if (warnings.length > 0) { |
| 257 | + console.log(' (Warnings: ' + warnings.length + ')'); |
| 258 | + } |
| 259 | + |
| 260 | + } catch (e) { |
| 261 | + console.log('FATAL: ' + e.message); |
| 262 | + failed++; |
| 263 | + } finally { |
| 264 | + if (browser) await browser.close(); |
| 265 | + srv.close(); |
| 266 | + } |
| 267 | + |
| 268 | + // ---------- Summary ---------- |
| 269 | + console.log('\n========================================'); |
| 270 | + console.log(' Passed: ' + passed + ' Failed: ' + failed); |
| 271 | + console.log('========================================\n'); |
| 272 | + process.exit(failed > 0 ? 1 : 0); |
| 273 | +} |
| 274 | + |
| 275 | +runTests(); |
0 commit comments