// Run: npm run test:devtool const assert = require('assert'); const fs = require('fs'); const path = require('path'); const vm = require('vm'); const babel = require('@babel/core'); const root = path.resolve(__dirname, '..'); const controlPath = 'src/playground/devtool-control.js'; const compile = file => babel.transformFileSync(path.join(root, file), { configFile: false, babelrc: false, presets: ['@babel/preset-env', '@babel/preset-react'] }).code; const controlCode = compile(controlPath); const homeCode = compile('src/playground/home.js'); let checks = 0; function test(name, action) { action(); checks++; console.log(`PASS ${name}`); } function page(storage = new Map(), {enabled = true, brokenStorage = false, immediate = false} = {}) { const starts = []; const effects = []; function detector(options) { starts.push(options); // Simulate a library probe firing during initialization with F12 already open. if (immediate) options.ondevtoolopen('already-open'); } detector.isSuspend = false; const window = { location: {href: '/editor.html'}, onbeforeunload: () => {}, cppCodeStopRun: () => effects.push('stop C++'), loadCppProject: () => effects.push('clear C++'), pythonCodeStopRun: () => effects.push('stop Python'), loadPythonProject: () => effects.push('clear Python'), unityInstance: {Quit: () => effects.push('quit engine')} }; const sessionStorage = { getItem(key) {if (brokenStorage) throw new Error('storage denied'); return storage.get(key) || null;}, setItem(key, value) {if (brokenStorage) throw new Error('storage denied'); storage.set(key, value);} }; const sandbox = { exports: {}, window, sessionStorage, localStorage: {getItem: () => '/index.html'}, vm: { stopAll: () => effects.push('stop Scratch'), editingTarget: {blocks: {deleteAllBlocks: () => effects.push('clear Scratch')}}, emitWorkspaceUpdate: () => effects.push('update workspace') }, require(name) { if (name === 'disable-devtool') return detector; if (name === './config.js') return {disabledev: enabled}; if (name === './devtool-control.js') return api; throw new Error(`Unexpected import: ${name}`); } }; const context = vm.createContext(sandbox); vm.runInContext(controlCode, context, {filename: controlPath}); const api = sandbox.exports; return {api, starts, effects, detector, window, context}; } // Use the actual options and destructive callback from each editor mode. function stageOptions(file) { const source = fs.readFileSync(path.join(root, file), 'utf8'); const ast = babel.parseSync(source, {configFile: false, babelrc: false, parserOpts: {plugins: ['jsx']}}); let options; function visit(node) { if (!node || typeof node !== 'object') return; if (node.type === 'ImportDeclaration') { assert.notStrictEqual(node.source.value, 'disable-devtool', `${file} bypasses shared control`); } if (node.type === 'CallExpression' && node.callee.name === 'initDisableDevtool') options = node.arguments[0]; for (const value of Object.values(node)) { if (Array.isArray(value)) value.forEach(visit); else if (value && typeof value === 'object') visit(value); } } visit(ast); assert(options, `${file} must initialize through shared control`); // Also verify the entire JSX file remains compatible with project Babel presets. compile(file); return source.slice(options.start, options.end); } const storage = new Map(); test('home exposes stopDev and persists it for navigation', () => { const home = page(storage); vm.runInContext(homeCode, home.context); home.window.stopDev(); assert.strictEqual(storage.get('stopDev'), '1'); assert.strictEqual(home.detector.isSuspend, true); }); for (const mode of ['stage-unity', 'stage-python-unity', 'stage-cpp-unity']) { const optionSource = stageOptions(`src/components/${mode}/stage-unity.jsx`); test(`${mode}: home -> editor with F12 open does not start detection or clear code`, () => { const editor = page(storage, {immediate: true}); const options = vm.runInContext(`(${optionSource})`, editor.context); editor.api.initDisableDevtool(options); assert.strictEqual(editor.starts.length, 0); assert.strictEqual(editor.detector.isSuspend, true); assert.strictEqual(editor.window.location.href, '/editor.html'); assert.deepStrictEqual(editor.effects, []); // A refresh or another mode initialization in this tab must stay stopped. editor.api.initDisableDevtool(options); assert.strictEqual(editor.starts.length, 0); }); test(`${mode}: stopDev blocks an already queued destructive callback`, () => { const editor = page(); const options = vm.runInContext(`(${optionSource})`, editor.context); editor.api.initDisableDevtool(options); assert.strictEqual(editor.starts.length, 1); const callback = editor.starts[0]; editor.window.stopDev(); assert.strictEqual(callback.ignore(), true); callback.ondevtoolopen('queued'); assert.strictEqual(editor.window.location.href, '/editor.html'); assert.deepStrictEqual(editor.effects, []); }); } test('normal session retains default detection and redirect', () => { const normal = page(); normal.api.initDisableDevtool(); assert.strictEqual(normal.starts.length, 1); assert.strictEqual(normal.starts[0].ignore(), false); normal.starts[0].ondevtoolopen(); assert.strictEqual(normal.window.location.href, '/index.html'); }); test('custom options, ignore and callback arguments remain intact', () => { const normal = page(); const seen = []; const next = () => {}; normal.api.initDisableDevtool({interval: 123, ignore: () => true, ondevtoolopen: (...args) => seen.push(args)}); assert.strictEqual(normal.starts[0].interval, 123); assert.strictEqual(normal.starts[0].ignore(), true); normal.starts[0].ondevtoolopen(7, next); assert.deepStrictEqual(seen, [[7, next]]); }); test('disabled configuration does not initialize; stopDev remains available', () => { const development = page(new Map(), {enabled: false}); development.api.initDisableDevtool(); assert.strictEqual(development.starts.length, 0); assert.strictEqual(typeof development.window.stopDev, 'function'); }); test('blocked storage still permits immediate stop on current page', () => { const broken = page(new Map(), {brokenStorage: true}); broken.api.initDisableDevtool(); broken.window.stopDev(); assert.strictEqual(broken.detector.isSuspend, true); assert.strictEqual(broken.starts[0].ignore(), true); broken.starts[0].ondevtoolopen(); broken.api.initDisableDevtool(); assert.strictEqual(broken.starts.length, 1); assert.strictEqual(broken.window.location.href, '/editor.html'); }); for (const file of ['src/playground/competition.js', 'src/playground/competition_sum.js']) { const optionsSource = stageOptions(file); test(`${file}: same stopDev state is honored`, () => { const competition = page(storage, {immediate: true}); competition.api.initDisableDevtool(vm.runInContext(`(${optionsSource})`, competition.context)); assert.strictEqual(competition.starts.length, 0); }); } console.log(`${checks} devtool regression checks passed`);