diff --git a/cypress/e2e/frontend.cy.ts b/cypress/e2e/frontend.cy.ts index 906a6e6ea..e39542d75 100644 --- a/cypress/e2e/frontend.cy.ts +++ b/cypress/e2e/frontend.cy.ts @@ -1,27 +1,30 @@ +import {serialiseState} from '../../shared/url-serialization.js'; import {assertNoConsoleOutput, stubConsoleOutput} from '../support/utils'; const PANE_DATA_MAP = { + codeEditor: {name: 'Editor', selector: 'new-editor'}, + compiler: {name: 'Compiler', selector: 'new-compiler'}, + conformance: {name: 'Conformance', selector: 'new-conformance'}, + output: {name: 'Output', selector: 'new-output-pane'}, executor: {name: 'Executor', selector: 'create-executor'}, opt: {name: 'Opt Viewer', selector: 'view-optimization'}, stackusage: {name: 'Stack Usage Viewer', selector: 'view-stack-usage'}, - preprocessor: {name: 'Preprocessor', selector: 'view-pp'}, + pp: {name: 'Preprocessor', selector: 'view-pp'}, ast: {name: 'Ast Viewer', selector: 'view-ast'}, - llvmir: {name: 'LLVM IR', selector: 'view-ir'}, - pipeline: {name: 'Pipeline', selector: 'view-opt-pipeline'}, + ir: {name: 'LLVM IR', selector: 'view-ir'}, + llvmOptPipelineView: {name: 'Pipeline', selector: 'view-opt-pipeline'}, device: {name: 'Device', selector: 'view-device'}, - mir: {name: 'MIR', selector: 'view-rustmir'}, - hir: {name: 'HIR', selector: 'view-rusthir'}, - macro: {name: 'Macro', selector: 'view-rustmacroexp'}, - core: {name: 'Core', selector: 'view-haskellCore'}, - stg: {name: 'STG', selector: 'view-haskellStg'}, - cmm: {name: 'Cmm', selector: 'view-haskellCmm'}, - // TODO find a way to properly hack the state URL to test these panes like the rust - // ones seem to be able to do. - // clojure_macro: {name: 'Clojure Macro', selector: 'view-clojuremacroexp'}, - // yul: {name: 'Yul', selector: 'view-yul'}, - dump: {name: 'Tree/RTL', selector: 'view-gccdump'}, - tree: {name: 'Tree', selector: 'view-gnatdebugtree'}, - debug: {name: 'Debug', selector: 'view-gnatdebug'}, + rustmir: {name: 'MIR', selector: 'view-rustmir'}, + rusthir: {name: 'HIR', selector: 'view-rusthir'}, + rustmacroexp: {name: 'Macro', selector: 'view-rustmacroexp'}, + haskellCore: {name: 'Core', selector: 'view-haskellCore'}, + haskellStg: {name: 'STG', selector: 'view-haskellStg'}, + haskellCmm: {name: 'Cmm', selector: 'view-haskellCmm'}, + yul: {name: 'Yul', selector: 'view-yul'}, + clojuremacroexp: {name: 'Clojure Macro', selector: 'view-clojuremacroexp'}, + gccdump: {name: 'Tree/RTL', selector: 'view-gccdump'}, + gnatdebugtree: {name: 'Tree', selector: 'view-gnatdebugtree'}, + gnatdebug: {name: 'Debug', selector: 'view-gnatdebug'}, cfg: {name: 'CFG', selector: 'view-cfg'}, explain: {name: 'Claude Explain', selector: 'view-explain'}, }; @@ -58,26 +61,25 @@ describe('Individual pane testing', () => { addPaneOpenTest(PANE_DATA_MAP.executor); addPaneOpenTest(PANE_DATA_MAP.opt); - addPaneOpenTest(PANE_DATA_MAP.preprocessor); + addPaneOpenTest(PANE_DATA_MAP.pp); addPaneOpenTest(PANE_DATA_MAP.ast); - addPaneOpenTest(PANE_DATA_MAP.llvmir); - addPaneOpenTest(PANE_DATA_MAP.pipeline); - // TODO: re-enable this when fixed addPaneOpenTest(PANE_DATA_MAP.device); - addPaneOpenTest(PANE_DATA_MAP.mir); - addPaneOpenTest(PANE_DATA_MAP.hir); - addPaneOpenTest(PANE_DATA_MAP.macro); - addPaneOpenTest(PANE_DATA_MAP.core); - addPaneOpenTest(PANE_DATA_MAP.stg); - addPaneOpenTest(PANE_DATA_MAP.cmm); - // TODO: bring back once #8215 lands - // addPaneOpenTest(PANE_DATA_MAP.yul); - addPaneOpenTest(PANE_DATA_MAP.dump); - addPaneOpenTest(PANE_DATA_MAP.tree); - addPaneOpenTest(PANE_DATA_MAP.debug); + addPaneOpenTest(PANE_DATA_MAP.ir); + addPaneOpenTest(PANE_DATA_MAP.llvmOptPipelineView); + addPaneOpenTest(PANE_DATA_MAP.device); + addPaneOpenTest(PANE_DATA_MAP.rustmir); + addPaneOpenTest(PANE_DATA_MAP.rusthir); + addPaneOpenTest(PANE_DATA_MAP.rustmacroexp); + addPaneOpenTest(PANE_DATA_MAP.haskellCore); + addPaneOpenTest(PANE_DATA_MAP.haskellStg); + addPaneOpenTest(PANE_DATA_MAP.haskellCmm); + addPaneOpenTest(PANE_DATA_MAP.yul); + addPaneOpenTest(PANE_DATA_MAP.clojuremacroexp); + addPaneOpenTest(PANE_DATA_MAP.gccdump); + addPaneOpenTest(PANE_DATA_MAP.gnatdebugtree); + addPaneOpenTest(PANE_DATA_MAP.gnatdebug); addPaneOpenTest(PANE_DATA_MAP.stackusage); addPaneOpenTest(PANE_DATA_MAP.explain); - // TODO: Bring back once #3899 lands - // addPaneOpenTest(PaneDataMap.cfg); + addPaneOpenTest(PANE_DATA_MAP.cfg); it('Output pane', () => { // Hide the dropdown @@ -91,22 +93,97 @@ describe('Individual pane testing', () => { cy.get('[data-cy="new-compiler-dropdown-btn"]:visible').click(); cy.get('[data-cy="new-editor-dropdown-btn"]:visible').click(); cy.get('[data-cy="new-conformance-btn"]:visible').click(); - // TODO: re-enable this when fixed cy.get('span.lm_title:visible').contains('Conformance'); + cy.get('span.lm_title:visible').contains('Conformance'); }); }); +// Programmatically built state containing all pane types for comprehensive testing. +// This replaced the hard-coded base64 URL that was 4812 characters long. +// The state is human-readable and maintainable, then serialized to the same compressed format. +function buildKnownGoodState() { + const editorId = 1; + const compilerId = 1; + const lang = 'c++'; + const source = '// Type your code here, or load an example.\nint square(int num) {\n return num * num;\n}'; + + // Helper functions to reduce boilerplate + const pane = (componentName: string, componentState: any) => ({ + type: 'component', + componentName, + componentState, + isClosable: true, + }); + + const stack = (content: any) => ({ + type: 'stack', + isClosable: true, + activeItemIndex: 0, + content: [content], + }); + + // Define minimal component states for each pane type + const paneStates: Record = { + codeEditor: {id: editorId, lang, source}, + compiler: {compiler: 'gdefault', id: compilerId, lang, source: editorId}, + conformance: {editorid: editorId, langId: lang, source}, + output: {compiler: compilerId}, + executor: {compiler: compilerId}, + opt: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + stackusage: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + pp: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + ast: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + ir: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + llvmOptPipelineView: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + device: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + rustmir: {compilerName: 'g++ default', editorid: editorId, id: compilerId, treeid: 0}, + rusthir: {compilerName: 'g++ default', editorid: editorId, id: compilerId, treeid: 0}, + rustmacroexp: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + haskellCore: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + haskellStg: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + haskellCmm: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + yul: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + clojuremacroexp: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + gccdump: { + compilerName: 'g++ default', + editorid: editorId, + id: compilerId, + treeid: 0, + gccDumpOptions: {}, + }, + gnatdebugtree: {compilerName: 'g++ default', editorid: editorId, id: compilerId, treeid: 0}, + gnatdebug: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + cfg: {editorid: editorId, id: compilerId}, + explain: {compilerName: 'g++ default', editorid: editorId, id: compilerId}, + }; + + // Build all panes from PANE_DATA_MAP + const allPanes = Object.keys(PANE_DATA_MAP).map(key => stack(pane(key, paneStates[key]))); + + // Chunk panes into rows of 8 for a more reasonable layout + const panesPerRow = 8; + const rows = []; + for (let i = 0; i < allPanes.length; i += panesPerRow) { + rows.push({ + type: 'row', + content: allPanes.slice(i, i + panesPerRow), + }); + } + + return { + version: 4, + content: [{type: 'column', content: rows}], + }; +} + describe('Known good state test', () => { beforeEach(() => { - cy.visit( - // This URL manually created. If you need to update, run a local server like the docs/UsingCypress.md say, - // then paste this into your browser, make the changes, and then re-share as a full link. - 'http://localhost:10240/#z:OYLghAFBqd5TB8IAsQGMD2ATApgUWwEsAXTAJwBoiQIAzIgG1wDsBDAW1xAHIBGHpTqYWJAMro2zEHwAsQkSQCqAZ1wAFAB68ADIIBWMyozYtQ6AKQAmAELWblNc3QkiI2q2wBhTIwCuHCwgVpSeADJELLgAcgEARrjkIPIADpgqpG4sPv6BwZRpGa4iEVGxHAlJ8k64LlliJGzkJDkBQSE1dSINTSSlMfGJyY6Nza15HaN9kQMVQ7IAlI6YfuTo3DwA9JsA1AAqAJ4puDsHK%2BQ7WHg7KIm4lDsUO4yYbNg7pju4mpwpzAB0Fh0AEFIiQdioAI5%2BJq4CBgnYsAILHYWADsdhBO2xO3IuBIqxYiICOwAVMSOBYAMyY4HogAiPCWjF4AFZBEEeHpKJheF57PYIed1qirFSBJQSLomUsANYgVk6Yy8WSCDgKpWc7m8niCFQgJVSrlMyhwWBoLAsYTkDimdbUDzEMjkIjYIwmMwASTdlls9mWq3WvG2%2ByOJzOq0uOBOtzxDyeLzeHyJ31%2BAKBoNEEOhsPhWaRHBR6NpONx%2BMJFLJFOptIZJpZPHZlC1gh1PitFFtLBFADUiLgAO6JHYQQikJ7WcULQRGvQLJa3N5DCDMlVq4Ks5vSnm8PUGyXSpZmqBoch%2BFQkFBEKg0CBYDgpJiJaKcDbAAW2HZ4OhsPyMEiCLgToUK6RjCKIEhSNwcgKKIqgaNoxqUIYfCOLgzjFEEECeOMQSoeEMzlJURiFJkIi4SR6RkSw/REUMqGdJhPRjL4bRGIx9RTLRgxJAxUwUXxvTcXMvFLCQeK4KBOiro2HLbjqABK57ggAEp6Ck7H2g7Du%2Bfqft%2Bv7/iOY7OqK4oPD4D5Phck58NOB7GvOlCLngSQrsqPCqpQ6pWJuLY7rqjj7rOMqUPKshKg2VJyUhOozoepqIMeqAYJgVnMDetD3o%2BGUgMABl/iQQhMCQiT6hAcTbnEkRNAcvAStV7DkAcADycT6LURoSveXCiC1LCMHVSF4HEfjAF4UiMPq/CCHgXbANIw3Xp1RAAG64NN3LfLUfilfVghguh26MEQcTkLVPh4Nu4lEOqM2UOt5BxOkuD0rg80nWYCV0CYwAqFpA4tccnISuB4iSNIMFg/BWjbihximOYH4OCdcT6pASyYCkmHTa2j0ung6PuRx7jYSw3isXk%2BHk8JxGoaRmECQUVGYbT9FoRhnG9EzJMsMx0xlDx7H8ZTeEjEJhFC3ZAZrNBlADqYJBAyQnodglDZNv5Oo7Lpdj6bgP6FcZwE2WKdnxY5C64EublyhqHleT5fnybuQWGglyVoBebDoLK55sMA9y3tl1kvlwvC64KBX/oBJugahYOQZD8jQ2osNIfDvMeOTTMEYLImUUUWRMwzWRs7xHMrd0Iu5GLvP8%2BXwvc6LTfNI30vibgklutJHmay7PA9L7OyqAHJwAzpyNfgbhngqOJtmahOyWTlw62fZIVOS5y4yY7G5brFrv6u7jmJcgaArCQKS7Q6d5pav5Bh2%2BU/RwBoRx26CeKEn0Ep4oMOIW5PDAc50Uj7Rkv3Q%2BPAWq7WvuCTAdAdYvxnkbCAK9rKLw3oeK2NtaC73XL5A%2B2oj7BQ9klU8ylbToHIJgb4KRb4hwyk/COyDDYx3fuOAmYFv4Q1/rBZQadAEGHYuhKuWEcIt2ptgduzMi7kUkbI6iMj641zYgxURXQ%2BZcUlgXQSLFa6twFrMOmYkJJSQgTFYhPAlIXh2AAWR9jQnY%2BBNApFMBkEQml%2BxDguJHfWbC54mQnGbCy98MHrwtnOHBrk8EOwIc7KBe4T5RLCiAAAbJFXg0UiGtldpvO2VI0T/DFGKNErIACcaJyl8D4FYKwaIPJWHXHwHQmoB6RJNMlJK3Tz4gG2ugXaFBb5ND%2BuoUw6ExAoEwAOEGghGFsEwmMqIjBJnTP8owoY%2BUUH/koBsx%2Br5XZ7JgeIKZMztz9OBOQP6rt%2BkNGIJyQQideEyD/nBQRcN3SIwwMjYwp0iaY2xlkXGPJ8aug2vAJYZ5RC3VwHsTAvh/kyyDHxe5SyJmnNmZKCS%2B15agPAX3SxuSeAuJ2qZPxNhp4BJHPyPSDhnELwiQ5FJ29bapMVHE7y%2B8tYkOSZ08heV2AkBGmNTuQcsphKYQcng5LKWz1jpw%2BO/Cf4vP4QAj56jOakwkQYqRMjS7yJ1Yo1mOi6aV00fzHmGimLaPzqa72%2Bi1Hizbia%2Bipiu7mIJTkgKABxaIwI9g7DeqNYA%2BwJJeO0r41hs9jacMXqE9Ka8zZYMts5a2MT3INj3oQ7lgVj5Mr5b068DCJXPilTK1%2B8rnSKszsKDYIZDjHFOOcKM1xYz3EeBcRM7xPipgfOmEECIoQwjxHmcEBYiwYgzKWPEBJyBEgLFWAsNYMx1ixe6t0M0LFep1GEMIPY7E7HUuGnxSDaWytQUE025ll4lqvebfNW8007w5U7bdPKH1n3NCAFAbAVCynQowHwcZg63uYdKqNhVK0gU/kq55UN/7vIziIzV4ic4KLzsY9m%2BrsgKOw8oq1XMHVUzNdaiWtr2b2paAoyj7c3XdwNFunN3qVJeBvXiY9k8z2vxjaZWy8aH6YI6Y%2B3BGa1ycuze0t2D67bsqioSgKQnP1IC/VjIqIGE37PDuBrj2y35AQVTBhg/5EgAFpTBSAOBkaaoMSqmY4EQFQagN3ciM6VcgJm3GOaAjip5UEVWpwQuqkjWRs4U0NRhuiFdsMlxZmXF1FcVHN0NYl515GEuqOIzR%2BLMg6OgXuiAtgYDN2epzcrDjkadNUvnrGvjN6NOCek6mkTMnMmeXiW%2B3NpDT6ewFQs4VwBi0abA%2BW3TUGuFfwgnB15AjAtIY1WI0LucabZfprFg1jq8MreC9XJLjqUtGMi4YnmNrMOiTXfR3uGt5M6l9f6wNuBg3ldPXrCl3Hqu8ZCXVgTjL8lNfTfg8TCSrFJI/T1xgjBVocGVuoIgxxPq4ABrfHgJm6AsEwCZ4gKhJDkGwCZ1aUg/C4BM%2BwLgKgeDbj2cNiD7C5qI2YCZlQBwKgIu3NgAIKQUd/kYCZjgOA/wbAlPpqthnbPuciJaNQ0JWCuCkB539ahrOPJ4X5%2BDbzZvcjQODyH0PYfoRmIjzOVqgIADE/DdkwkfI32BVZ4G0EqDII0mgAHVXSXg3IaMxG6BCMYHmVmHcOZhPZG1Vy9cavvhKTUJ6Jz7M3tZzSDzeSmUo/r/QBhoA31MPyp5VuVHDhfcMmyr6baq5vbdQ2Fx1EWpZGuLrhtbNEtv7ctShhujeMt1xO4djunuGMlYHsx1jYg9jeqD9TwJDLPvoIyg137LLYmx8Bx1hP2C2WteyfHxr8pWSyH%2BHwNJ%2B%2BD%2BH4P/IBsTTOUtLaYk0HPSv0pHocHD0wAUedgWTioX0GwKi5MyyzaSvC/J1VUQyASMFFTy29z7ygXUDxBSBoXWEcyeGOTgWeyjl0x42CWvSn0TSnCjz%2B2fXlCsHKV3yP2IP3wAA4X0uVJM81E8et0A6AM9xUhsy0x8xtq1kNOoTczdNFeAh1cwwQFheBzsPUrsOsvBjcR9g9o13t0Cl5MC71k1mUn1WUF9X0N9qCyFek8BVoiB7RbwtCdCNhWDDNlcACAt05gD5tuCyYK9iMq9dEa91tiNNs0t2DzV29XDSNUtTsjtqNO8pZcsvd6w2RrteA3ptDexvFOMXtz0jJpC71%2BMI9sDGs59RM2tF81Cus5wk9%2BUU9/1wcvAOAOBBss9mCc9IM88P8JtwYi9AD1dhFLCLdrCltpEttos685EG8XCGjCMqNksCMdsvCu8nVei9s/DdEAje8RCmMWNl5CjR8yjYjQ9as5CZ8V8UiWsKCJMr91Dut%2BU6ETBIhijQ5SjoiK0KjxtkIQCe9LtgjRCTA/BrgXE/g2BIhkD/EpCljJ9b1ViU11jV8OUMjeUnJT849JNft8DSDd8rAalSDZBCkYS4S0QqQPJ18wSNDYB%2BVf01NGCSitNJDyj38Lia1Aw61dgG1wxm0rgYw7h4xO1Xhu0Uwfg%2B1cBAQB0sxeCR0ERx1URJ0sQcQZ0KwF1yQl0aQV00RGQhDAifcoFgRbEJ4KtTjUC4iw8Vifs1ilD58xN1R2VATr9ekzwLx7NMo74mC8SWDzjFVfNTCEM6jLjuitU0NwtlsuiHCcNDVnDvD7StFdtMt%2BjvTBjq9KNjsyNPSJibjZIOsbFwQ7Ej15S3jXslTPiMDvi1TfiNTUis00l/gdAxQqRqlSDalSC0lt9ylyDdSaD%2BVgB0B0A2cHxhlsBsA8RHNlYQt7pJoWz3B7o4gXhfYVAOzMUepTBsAAB9GAg2IgbQdySnE4lAgJWafEF4qafst/D%2BT/ZgEnXAYclQPwOgBgScpYK0vhMwoRSgYAW6P4XAY3XAZc%2B6WHNgekdnILeHNHG8iUDc2Jc6QGQFTsiUZoRgB8h8bcRiICMZRzWJLOJo9DZ0z010mLDo/DFvdwr01vF0oM3wkMrvJYFQE6KHb8zFb2EgPsvCnFUVACorbkUAnuSgPwV0V8wQVaLGIii3YrKY/vLwVjPYCSTYBSPYMIeYxUkPCfZM%2BrVMxQ5rTYqkf4SpffHQAs0gtEPgKkWQRUEIcslfeUPgIpBS6E2QUg8pNJHSvSgylEkIwKcE9JKwf4NENJayqwCKKwWS%2BStEE/XgM/dUC/JfRrEEzy37R6DxIIWQIAA%3D%3D', - { - onBeforeLoad: win => { - stubConsoleOutput(win); - }, + const state = buildKnownGoodState(); + const hash = serialiseState(state); + cy.visit(`http://localhost:10240/#${hash}`, { + onBeforeLoad: win => { + stubConsoleOutput(win); }, - ); + }); }); afterEach(() => { @@ -120,8 +197,5 @@ describe('Known good state test', () => { const pane = PANE_DATA_MAP[paneId]; cy.get('span.lm_title:visible').contains(pane.name); } - - cy.get('span.lm_title:visible').contains('Output'); - cy.get('span.lm_title:visible').contains('Conformance'); }); }); diff --git a/shared/assert.ts b/shared/assert.ts index 1826ffd59..58f250019 100644 --- a/shared/assert.ts +++ b/shared/assert.ts @@ -22,8 +22,8 @@ // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. -import {isString} from '../shared/common-utils.js'; -import {parse} from '../shared/stacktrace.js'; +import {isString} from './common-utils.js'; +import {parse} from './stacktrace.js'; // This file defines three assert utilities: // assert(condition, message?, extra_info...?): asserts condition diff --git a/shared/rison.ts b/shared/rison.ts index 5f55f5bdf..e7dfe331c 100644 --- a/shared/rison.ts +++ b/shared/rison.ts @@ -1,7 +1,7 @@ // Based on https://github.com/Nanonid/rison at e64af6c096fd30950ec32cfd48526ca6ee21649d (Jun 9, 2017) -import {isString} from '../shared/common-utils.js'; import {assert, unwrap} from './assert.js'; +import {isString} from './common-utils.js'; ////////////////////////////////////////////////// // diff --git a/shared/url-serialization.ts b/shared/url-serialization.ts new file mode 100644 index 000000000..7bc017adb --- /dev/null +++ b/shared/url-serialization.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2025, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * 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. +// +// 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. + +import lzstring from 'lz-string'; +import * as rison from './rison.js'; + +/** + * ConfigMinifier - Reimplementation of GoldenLayout's ConfigMinifier + * + * Minifies and unminifies configs by replacing frequent keys and values + * with one letter substitutes using base36 encoding. + * + * This is a Node-compatible reimplementation that doesn't depend on + * the browser-only GoldenLayout library. + */ +class ConfigMinifier { + private readonly _keys: string[]; + private readonly _values: (string | boolean)[]; + + constructor() { + // Array position matters - these map to base36 characters (0-9, a-z) + this._keys = [ + 'settings', + 'hasHeaders', + 'constrainDragToContainer', + 'selectionEnabled', + 'dimensions', + 'borderWidth', + 'minItemHeight', + 'minItemWidth', + 'headerHeight', + 'dragProxyWidth', + 'dragProxyHeight', + 'labels', + 'close', + 'maximise', + 'minimise', + 'popout', + 'content', + 'componentName', + 'componentState', + 'id', + 'width', + 'type', + 'height', + 'isClosable', + 'title', + 'popoutWholeStack', + 'openPopouts', + 'parentId', + 'activeItemIndex', + 'reorderEnabled', + 'borderGrabWidth', + ]; + + this._values = [ + true, + false, + 'row', + 'column', + 'stack', + 'component', + 'close', + 'maximise', + 'minimise', + 'open in new window', + ]; + + if (this._keys.length > 36) { + throw new Error('Too many keys in config minifier map'); + } + } + + /** + * Takes a GoldenLayout configuration object and replaces its keys + * and values recursively with one letter counterparts + */ + minifyConfig(config: any): any { + const min: any = {}; + this._nextLevel(config, min, '_min'); + return min; + } + + /** + * Takes a configuration Object that was previously minified + * using minifyConfig and returns its original version + */ + unminifyConfig(minifiedConfig: any): any { + const orig: any = {}; + this._nextLevel(minifiedConfig, orig, '_max'); + return orig; + } + + /** + * Recursive function, called for every level of the config structure + */ + private _nextLevel(from: any, to: any, translationFn: '_min' | '_max'): void { + for (const key in from) { + // Skip prototype properties + if (!from.hasOwnProperty(key)) continue; + + // For arrays, cast keys to numbers (not strings!) + // This is important because the single-char check in _min/_max + // should not trigger for numeric indices like "0", "1", etc. + let processedKey: string | number = key; + if (Array.isArray(from)) { + processedKey = parseInt(key, 10); + } + + // Translate the key to a one letter substitute + const minKey = this[translationFn](processedKey, this._keys); + + // For Arrays and Objects, create a new Array/Object and recurse + if (typeof from[key] === 'object' && from[key] !== null) { + to[minKey] = Array.isArray(from[key]) ? [] : {}; + this._nextLevel(from[key], to[minKey], translationFn); + } else { + // For primitive values, minify the value + to[minKey] = this[translationFn](from[key], this._values); + } + } + } + + /** + * Minifies value based on a dictionary + */ + private _min(value: any, dictionary: readonly (string | boolean)[]): any { + // If a value actually is a single character, prefix it with ___ + // to avoid mistaking it for a minification code + if (typeof value === 'string' && value.length === 1) { + return '___' + value; + } + + const index = dictionary.indexOf(value); + + // Value not found in the dictionary, return it unmodified + if (index === -1) { + return value; + } + + // Value found in dictionary, return its base36 counterpart + return index.toString(36); + } + + /** + * Unminifies value based on a dictionary + */ + private _max(value: any, dictionary: readonly (string | boolean)[]): any { + // Value is a single character - assume it's a translation + if (typeof value === 'string' && value.length === 1) { + return dictionary[parseInt(value, 36)]; + } + + // Value originally was a single character and was prefixed with ___ + if (typeof value === 'string' && value.substr(0, 3) === '___') { + return value[3]; + } + + // Value was not minified + return value; + } +} + +// Create a singleton instance +const configMinifier = new ConfigMinifier(); + +/** + * Minify a GoldenLayout config object + */ +export function minifyConfig(config: any): any { + return configMinifier.minifyConfig(config); +} + +/** + * Unminify a GoldenLayout config object + */ +export function unminifyConfig(config: any): any { + return configMinifier.unminifyConfig(config); +} + +/** + * Convert object to rison-encoded string + */ +export function risonify(obj: rison.JSONValue): string { + return rison.quote(rison.encode_object(obj)); +} + +/** + * Convert rison-encoded string to object + */ +export function unrisonify(text: string): any { + return rison.decode_object(decodeURIComponent(text.replace(/\+/g, '%20'))); +} + +/** + * Serialise state object to URL hash string + * + * Process: + * 1. Minify the config (replace common keys/values with single chars) + * 2. Rison encode the minified config + * 3. If compression saves >20%, compress with lzstring and wrap in {z: ...} + * 4. Return the final rison-encoded string + */ +export function serialiseState(stateText: any): string { + const ctx = minifyConfig({content: stateText.content}); + ctx.version = 4; + const uncompressed = risonify(ctx); + const compressed = risonify({z: lzstring.compressToBase64(uncompressed)}); + const MinimalSavings = 0.2; + if (compressed.length < uncompressed.length * (1.0 - MinimalSavings)) { + return compressed; + } + return uncompressed; +} + +/** + * Deserialise URL hash string to state object + * + * Process: + * 1. Rison decode the hash + * 2. If it contains {z: ...}, decompress the lzstring data + * 3. Rison decode again if decompressed + * 4. Unminify the config (expand single chars back to full keys/values) + * 5. Handle version migrations for old state formats + */ +export function deserialiseState(stateText: string): any { + let state; + try { + state = unrisonify(stateText); + if (state?.z) { + const data = lzstring.decompressFromBase64(state.z); + // lzstring returns empty string on failure rather than throwing + if (data === '') { + throw new Error('lzstring decompress error, url is corrupted'); + } + state = unrisonify(data); + } + } catch (ex) { + // If we can't parse it, return false so caller can handle + console.warn('Failed to deserialise state:', ex); + return false; + } + + // Handle version migrations + if (!state || state.version === undefined) return false; + + switch (state.version) { + case 4: + state = unminifyConfig(state); + break; + default: + // Versions 1-3 require GoldenLayout and Components, which are browser-only + // These should be handled by the frontend-specific code in static/url.ts + return state; + } + + return state; +} diff --git a/static/main.ts b/static/main.ts index d3d1992f5..1b191fe6a 100644 --- a/static/main.ts +++ b/static/main.ts @@ -45,6 +45,7 @@ let jsCookie = JsCookie; import {unwrap} from '../shared/assert.js'; import * as utils from '../shared/common-utils.js'; +import {unrisonify} from '../shared/url-serialization.js'; import {ParseFiltersAndOutputOptions} from '../types/features/filters.interfaces.js'; import {LanguageKey} from '../types/languages.interfaces.js'; import * as BootstrapUtils from './bootstrap-utils.js'; @@ -66,7 +67,7 @@ import {setupRealDark, takeUsersOutOfRealDark} from './real-dark.js'; import {Settings, SiteSettings} from './settings.js'; import {initialiseSharing} from './sharing.js'; import {Themer} from './themes.js'; -import * as url from './url.js'; +import {deserialiseState} from './url.js'; import {formatISODate, updateAndCalcTopBarHeight} from './utils.js'; import {Alert} from './widgets/alert.js'; import {HistoryWidget} from './widgets/history-widget.js'; @@ -256,7 +257,7 @@ function configFromEmbedded(embeddedUrl: string, defaultLangId: string) { // Old-style link? let params; try { - params = url.unrisonify(embeddedUrl); + params = unrisonify(embeddedUrl); } catch { document.write( '
' + @@ -285,7 +286,7 @@ function configFromEmbedded(embeddedUrl: string, defaultLangId: string) { ], }; } - return url.deserialiseState(embeddedUrl); + return deserialiseState(embeddedUrl); } function fixBugsInConfig(config: Partial): void { @@ -332,7 +333,7 @@ function findConfig( config = options.config; } else { try { - config = url.deserialiseState(window.location.hash.substring(1)); + config = deserialiseState(window.location.hash.substring(1)); } catch { // #3518 Alert the user that the url is invalid const alertSystem = new Alert(); diff --git a/static/sentry.ts b/static/sentry.ts index e7bd06777..ee8f563c0 100644 --- a/static/sentry.ts +++ b/static/sentry.ts @@ -25,9 +25,9 @@ import * as Sentry from '@sentry/browser'; import GoldenLayout from 'golden-layout'; import {parse} from '../shared/stacktrace.js'; +import {serialiseState} from '../shared/url-serialization.js'; import {options} from './options.js'; import {SiteSettings} from './settings.js'; -import {serialiseState} from './url.js'; let layout: GoldenLayout; let allowSendCode: boolean; diff --git a/static/sharing.ts b/static/sharing.ts index 9eb90aa91..a3330a6f4 100644 --- a/static/sharing.ts +++ b/static/sharing.ts @@ -28,12 +28,12 @@ import GoldenLayout from 'golden-layout'; import $ from 'jquery'; import _ from 'underscore'; import {unwrap} from '../shared/assert.js'; +import {serialiseState} from '../shared/url-serialization.js'; import * as BootstrapUtils from './bootstrap-utils.js'; import {sessionThenLocalStorage} from './local.js'; import {options} from './options.js'; import {SentryCapture} from './sentry.js'; import {Settings, SiteSettings} from './settings.js'; -import * as url from './url.js'; import ClickEvent = JQuery.ClickEvent; @@ -106,7 +106,7 @@ export class SharingBase { // Update embedded links if present (works in both modes) if (options.embedded) { const strippedToLast = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1); - $('a.link').prop('href', strippedToLast + '#' + url.serialiseState(config)); + $('a.link').prop('href', strippedToLast + '#' + serialiseState(config)); } } @@ -417,7 +417,7 @@ export class Sharing extends SharingBase { Sharing.getShortLink(config, root, done); return; case LinkType.Full: - done(null, window.location.origin + root + '#' + url.serialiseState(config), false); + done(null, window.location.origin + root + '#' + serialiseState(config), false); return; case LinkType.Embed: { const options: Record = {}; @@ -436,7 +436,7 @@ export class Sharing extends SharingBase { private static getShortLink(config: any, root: string, done: CallableFunction): void { const useExternalShortener = options.urlShortenService !== 'default'; const data = JSON.stringify({ - config: useExternalShortener ? url.serialiseState(config) : config, + config: useExternalShortener ? serialiseState(config) : config, }); $.ajax({ type: 'POST', @@ -485,7 +485,7 @@ export class Sharing extends SharingBase { const path = (readOnly ? 'embed-ro' : 'e') + parameters + '#'; - return location + path + url.serialiseState(config); + return location + path + serialiseState(config); } private static storeCurrentConfig(config: any, extra: string): void { diff --git a/static/tests/url-tests.ts b/static/tests/url-tests.ts new file mode 100644 index 000000000..c4255863a --- /dev/null +++ b/static/tests/url-tests.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2025, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * 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. +// +// 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. + +import {describe, expect, it} from 'vitest'; +import {deserialiseState} from '../url.js'; + +describe('Historical URL Backward Compatibility', () => { + describe('Version 4 (modern minified format)', () => { + const findComponent = (content: any[], name: string): any => { + for (const item of content) { + if (item.componentName === name) return item; + if (item.content && Array.isArray(item.content)) { + const found = findComponent(item.content, name); + if (found) return found; + } + } + return null; + }; + + it('should deserialize uncompressed minified URL', () => { + const urlHash = + 'g:!((g:!((g:!((h:codeEditor,i:(filename:%271%27,fontScale:14,fontUsePx:%270%27,j:1,lang:c%2B%2B,source:%27//+Type+your+code+here,+or+load+an+example.%0Aint+square(int+num)+%7B%0A++++return+num+*+num%3B%0A%7D%27),l:%275%27,n:%270%27,o:%27C%2B%2B+source+%231%27,t:%270%27)),k:50,l:%274%27,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((h:compiler,i:(compiler:g152,filters:(b:%270%27,binary:%271%27,binaryObject:%271%27,commentOnly:%270%27,debugCalls:%271%27,demangle:%270%27,directives:%270%27,execute:%271%27,intel:%270%27,libraryCode:%270%27,trim:%271%27,verboseDemangling:%270%27),flagsViewOpen:%271%27,fontScale:14,fontUsePx:%270%27,j:1,lang:c%2B%2B,libs:!(),options:%27%27,overrides:!(),selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1),l:%275%27,n:%270%27,o:%27+x86-64+gcc+15.2+(Editor+%231)%27,t:%270%27)),k:50,l:%274%27,n:%270%27,o:%27%27,s:0,t:%270%27)),l:%272%27,n:%270%27,o:%27%27,t:%270%27)),version:4'; + + const state = deserialiseState(urlHash); + + expect(state).toBeTruthy(); + expect(state.version).toBe(4); + expect(state.content).toBeTruthy(); + expect(Array.isArray(state.content)).toBe(true); + + const editor = findComponent(state.content, 'codeEditor'); + expect(editor).toBeTruthy(); + expect(editor.componentState.source).toContain('square'); + + const compiler = findComponent(state.content, 'compiler'); + expect(compiler).toBeTruthy(); + expect(compiler.componentState.compiler).toBe('g152'); + }); + + it('should deserialize compressed minified URL', () => { + const urlHash = + 'z:OYLghAFBqd5TKALEBjA9gEwKYFFMCWALugE4A0BIEAZgQDbYB2AhgLbYgDkAjF%2BTXRMiAZVQtGIHgBYBQogFUAztgAKAD24AGfgCsp5eiyahUAUgBMAIUtXyKxqiIEh1ZpgDC6egFc2TEAtZdwAZAiZsADk/ACNsUgMAB3QlYhcmL19/QNlk1OchMIjotjiEnntsRwKmESIWUiJMvwCgyur0uoaiIqjY%2BIMlesbm7Lah7t6SsqkASnt0H1JUTi4AejWAahiWTGB47d390kO9%2BK55%2Bm4AVn4Arh1ydG4PW1tNpUXl7E3LAGY%2BOQiNoLvMANYga5aQzcaT8NiQ6H3R7PLj8JQgaHAh4XchwWAoCaoME%2BJQsfaUagYNiJBjxSLsVbqAAcADYALSs6SbYCoVCbHjXAB0Fn42EIJFIBEwBkEwjEEk4MjkwmUak0OPI%2BgqDmwTnSbiYnm8LQMoXCfVKAwqeTSQlGARtKTtTCm/XK7T1NS6IxN2R1VS9nWGPQt02t9hDDsGIbdVvK8yIpGw2GlmMuNzuIKe3C6xM2ynJPwAagRsAB3A4sjlcnl8gXCiybCD4YhkX4WAHkTZeGl0k7/HizfjYnSzeZIbC7AYQDNcOHkBFQ8jI/io9GYoEg8fkCHLq5cP5ZzXrrc4%2Bb4hDwCAoam0xgUKgQO/9kDAQUWAQMIjxDEQGLZjE4QNAAntwgJAawpAgQA8jEuheuB/DUhwwgwUw9BgZqOAxD4wAeBI9AYrw/A4GwxjAJI2EEMm%2BoAG7YMRjzYOoeo%2BD%2BSGUMIVTZvQBAxKQoFeDg2ZJgQCIkeQDGkDEKTYAAItg5EmHxJjbgIRjAEopYVjBiTMJxcqiOIkjKkZaoaNm2qGBRaBvNYhj8RikDzOgiQ1MR7IwaKTzSVKODObOnr6q4EDuNG0isuQ5rFO6STOjUEVRbaNRxjMAYdEIPpNH6ASRcF3qxmGcU6lGuUgPlEyNGl1rzJ8SwrHMMJcLcK7ZqimzVpy3K8vygois2raSh2ALDmeY7goizULkuSLtdwG5Yupl43iAiSJJSz5GCY7KCKQ5HYoC4ptv5srfvE7KTtOpBMfwRkKqZsjmSolmatZSYpmmWhzq1q45lwqjJokpDoCsSifCcMHsYk7GdWy3V1n1jaDRK7aDt2vb3gcg5jaOoLkFdOAJEFB4zVNf2nhiS3npN%2B7cEebUngt434wePkU8zeM7tJqSuNIQA%3D%3D'; + + const state = deserialiseState(urlHash); + + expect(state).toBeTruthy(); + expect(state.version).toBe(4); + expect(state.content).toBeTruthy(); + expect(Array.isArray(state.content)).toBe(true); + + const editor = findComponent(state.content, 'codeEditor'); + expect(editor).toBeTruthy(); + expect(editor.componentState.source).toContain('badger'); + + const compiler = findComponent(state.content, 'compiler'); + expect(compiler).toBeTruthy(); + expect(compiler.componentState.compiler).toBe('g152'); + + const pp = findComponent(state.content, 'pp'); + expect(pp).toBeTruthy(); + + const stackUsage = findComponent(state.content, 'stackusage'); + expect(stackUsage).toBeTruthy(); + }); + }); + + describe('Version 2 (JSON format from 2013)', () => { + it('should deserialize ICC example with filters', () => { + // From etc/oldhash.txt + const urlHash = + '%7B%22version%22%3A2%2C%22source%22%3A%22%23include%20%3Cxmmintrin.h%3E%5Cn%5Cnvoid%20f(__m128%20a%2C%20__m128%20b)%5Cn%7B%5Cn%20%20%2F%2F%20I%20am%20a%20walrus.%5Cn%7D%22%2C%22compiler%22%3A%22%2Fhome%2Fmgodbolt%2Fapps%2Fintel-icc-oss%2Fbin%2Ficc%22%2C%22options%22%3A%22-O3%20-std%3Dc%2B%2B0x%22%2C%22filterAsm%22%3A%7B%22labels%22%3Atrue%2C%22directives%22%3Atrue%7D%7D'; + + const state = deserialiseState(urlHash); + + expect(state).toBeTruthy(); + expect(state.version).toBe(4); + expect(state.content).toBeTruthy(); + expect(Array.isArray(state.content)).toBe(true); + expect(state.content.length).toBeGreaterThan(0); + + const row = state.content[0]; + expect(row.type).toBe('row'); + expect(Array.isArray(row.content)).toBe(true); + expect(row.content.length).toBe(2); + + const editor = row.content.find((c: any) => c.componentName === 'codeEditor'); + expect(editor).toBeTruthy(); + expect(editor.componentState.source).toContain('#include '); + expect(editor.componentState.source).toContain('I am a walrus'); + + const compiler = row.content.find((c: any) => c.componentName === 'compiler'); + expect(compiler).toBeTruthy(); + expect(compiler.componentState.compiler).toBe('/home/mgodbolt/apps/intel-icc-oss/bin/icc'); + expect(compiler.componentState.options).toBe('-O3 -std=c++0x'); + + const filters = compiler.componentState.filters; + expect(filters.labels).toBe(true); + expect(filters.directives).toBe(true); + }); + }); + + describe('Version 3 (Rison format)', () => { + it('should deserialize GCC 7 widgets example', () => { + // From etc/oldhash.txt + const urlHash = + "compilers:!((compiler:g7snapshot,options:'-std%3Dc%2B%2B1z+-O3+',source:'%23include+%3Cvector%3E%0A%0Astruct+Widget+%7B%0A++int+n%3B%0A++double+x,+y%3B%0A++Widget(const+Widget%26+o)+:+x(o.x),+y(o.y),+n(o.n)+%7B%7D%0A++Widget(int+n,+double+x,+double+y)+:+n(n),+x(x),+y(y)+%7B%7D%0A%7D%3B%0A%0Astd::vector%3CWidget%3E+vector%3B%0Aconst+int+N+%3D+1002%3B%0Adouble+a+%3D+0.1%3B%0Adouble+b+%3D+0.2%3B%0A%0Avoid+demo()+%7B%0A++vector.reserve(N)%3B%0A++for+(int+i+%3D+01%3B+i+%3C+N%3B+%2B%2Bi)%0A++%7B%0A%09Widget+w+%7Bi,+a,+b%7D%3B%0A%09vector.push_back(w)%3B+//+or+vector.push_back(std::move(w))%0A++%7D%0A%7D%0A%0Aint+main()%0A%7B%0A+%0A+%0A%7D%0A')),filterAsm:(colouriseAsm:!t,commentOnly:!t,directives:!t,intel:!t,labels:!t),version:3"; + + const state = deserialiseState(urlHash); + + expect(state).toBeTruthy(); + expect(state.version).toBe(4); + expect(state.content).toBeTruthy(); + expect(Array.isArray(state.content)).toBe(true); + + const row = state.content[0]; + expect(row.type).toBe('row'); + expect(Array.isArray(row.content)).toBe(true); + expect(row.content.length).toBe(2); + + const editor = row.content.find((c: any) => c.componentName === 'codeEditor'); + expect(editor).toBeTruthy(); + expect(editor.componentState.source).toContain('#include '); + expect(editor.componentState.source).toContain('struct Widget'); + expect(editor.componentState.source).toContain('std::vector vector'); + expect(editor.componentState.options.colouriseAsm).toBe(true); + + const compiler = row.content.find((c: any) => c.componentName === 'compiler'); + expect(compiler).toBeTruthy(); + expect(compiler.componentState.compiler).toBe('g7snapshot'); + expect(compiler.componentState.options).toBe('-std=c++1z -O3 '); + + const filters = compiler.componentState.filters; + expect(filters.commentOnly).toBe(true); + expect(filters.directives).toBe(true); + expect(filters.intel).toBe(true); + expect(filters.labels).toBe(true); + }); + + it('should deserialize GCC 4.7.4 with compressed source', () => { + // From etc/oldhash.txt - compressed with lzstring + const urlHash = + 'compilers:!((compiler:g474,options:%27%27,sourcez:PQKgBALgpgzhYHsBmYDGCC2AHATrGAlggHYB0pamGUx8AFlHmEgjmADY0DmEdAXACgAhiNFjxEyVOkzZw2QsVLl85WvVrVG7TolbdB7fsMmlx0xennLNsddu37Dy0%2BenXbwx8%2B7vPo/7OfoGaIMACAgS0YBhCUQAUUfBCOFyoADSUxHBodClgICApXABuAJRgAN4CYGB4EACuOMRgAIwATADcAgC%2BQAA)),filterAsm:(binary:!t,colouriseAsm:!t,commentOnly:!t,directives:!t,intel:!t,labels:!t),version:3'; + + const state = deserialiseState(urlHash); + + expect(state).toBeTruthy(); + expect(state.version).toBe(4); + expect(state.content).toBeTruthy(); + expect(Array.isArray(state.content)).toBe(true); + + const row = state.content[0]; + expect(row.type).toBe('row'); + expect(Array.isArray(row.content)).toBe(true); + expect(row.content.length).toBe(2); + + const editor = row.content.find((c: any) => c.componentName === 'codeEditor'); + expect(editor).toBeTruthy(); + expect(editor.componentState.source).toBeTruthy(); + expect(typeof editor.componentState.source).toBe('string'); + expect(editor.componentState.source.length).toBeGreaterThan(0); + expect(editor.componentState.source).toContain('int'); + expect(editor.componentState.options.colouriseAsm).toBe(true); + + const compiler = row.content.find((c: any) => c.componentName === 'compiler'); + expect(compiler).toBeTruthy(); + expect(compiler.componentState.compiler).toBe('g474'); + expect(compiler.componentState.options).toBe(''); + + const filters = compiler.componentState.filters; + expect(filters.binary).toBe(true); + expect(filters.commentOnly).toBe(true); + expect(filters.directives).toBe(true); + expect(filters.intel).toBe(true); + expect(filters.labels).toBe(true); + }); + }); + + describe('Invalid/Edge Cases', () => { + it('should handle empty string', () => { + const state = deserialiseState(''); + expect(state).toBe(false); + }); + + it('should handle invalid JSON', () => { + expect(() => { + deserialiseState('%7Binvalid'); + }).toThrow(); + }); + + it('should handle invalid rison', () => { + expect(() => { + deserialiseState('invalid:rison:data'); + }).toThrow(); + }); + + it('should handle corrupt lzstring data', () => { + // State with 'z' field but invalid base64 + expect(() => { + deserialiseState('(z:invalid_base64_data)'); + }).toThrow(); + }); + }); +}); diff --git a/static/url.ts b/static/url.ts index d6a33ec41..71fbc3a93 100644 --- a/static/url.ts +++ b/static/url.ts @@ -22,10 +22,9 @@ // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. -import GoldenLayout from 'golden-layout'; import lzstring from 'lz-string'; import _ from 'underscore'; -import * as rison from '../shared/rison.js'; +import * as urlSerialization from '../shared/url-serialization.js'; import * as Components from './components.js'; export function convertOldState(state: any): any { @@ -67,7 +66,7 @@ export function loadState(state: any): any { state = convertOldState(state); break; // no fall through case 4: - state = GoldenLayout.unminifyConfig(state); + state = urlSerialization.unminifyConfig(state); break; default: throw new Error("Invalid version '" + state.version + "'"); @@ -75,26 +74,18 @@ export function loadState(state: any): any { return state; } -export function risonify(obj: rison.JSONValue): string { - return rison.quote(rison.encode_object(obj)); -} - -export function unrisonify(text: string): any { - return rison.decode_object(decodeURIComponent(text.replace(/\+/g, '%20'))); -} - export function deserialiseState(stateText: string): any { let state; let exception; try { - state = unrisonify(stateText); + state = urlSerialization.unrisonify(stateText); if (state?.z) { const data = lzstring.decompressFromBase64(state.z); // If lzstring fails to decompress this it'll return an empty string rather than throwing an error if (data === '') { throw new Error('lzstring decompress error, url is corrupted'); } - state = unrisonify(data); + state = urlSerialization.unrisonify(data); } } catch (ex) { exception = ex; @@ -112,15 +103,3 @@ export function deserialiseState(stateText: string): any { if (exception) throw exception; return loadState(state); } - -export function serialiseState(stateText: any): string { - const ctx = GoldenLayout.minifyConfig({content: stateText.content}); - ctx.version = 4; - const uncompressed = risonify(ctx); - const compressed = risonify({z: lzstring.compressToBase64(uncompressed)}); - const MinimalSavings = 0.2; // at least this ratio smaller - if (compressed.length < uncompressed.length * (1.0 - MinimalSavings)) { - return compressed; - } - return uncompressed; -} diff --git a/static/widgets/site-templates-widget.ts b/static/widgets/site-templates-widget.ts index 14c45ff1f..95056e3be 100644 --- a/static/widgets/site-templates-widget.ts +++ b/static/widgets/site-templates-widget.ts @@ -26,11 +26,11 @@ import GoldenLayout from 'golden-layout'; import $ from 'jquery'; import {assert, unwrap, unwrapString} from '../../shared/assert.js'; import {escapeHTML} from '../../shared/common-utils.js'; +import {serialiseState} from '../../shared/url-serialization.js'; import {SiteTemplateConfiguration, UserSiteTemplate} from '../../types/features/site-templates.interfaces.js'; import * as BootstrapUtils from '../bootstrap-utils.js'; import {localStorage} from '../local.js'; import {Settings} from '../settings.js'; -import * as url from '../url.js'; import {getStaticImage} from '../utils'; import {Alert} from './alert.js'; @@ -54,7 +54,7 @@ class SiteTemplatesWidget { } saveCurrentAsTemplate() { const config = this.layout.toConfig(); - const data = url.serialiseState(config); + const data = serialiseState(config); this.alertSystem.enterSomething('Template Name', '', '', { yes: name => { const userTemplates: Record = JSON.parse( diff --git a/test/url-serialization.ts b/test/url-serialization.ts new file mode 100644 index 000000000..d2b1ea9fe --- /dev/null +++ b/test/url-serialization.ts @@ -0,0 +1,96 @@ +import {describe, expect, it} from 'vitest'; +import * as urlSerialization from '../shared/url-serialization.js'; + +describe('URL Serialization', () => { + it('should serialise and deserialise a simple state', () => { + const state = { + content: [ + { + type: 'row', + content: [ + { + type: 'component', + componentName: 'codeEditor', + componentState: { + id: 1, + lang: 'c++', + }, + }, + ], + }, + ], + }; + + const serialized = urlSerialization.serialiseState(state); + expect(serialized).toBeTruthy(); + expect(typeof serialized).toBe('string'); + + const deserialized = urlSerialization.deserialiseState(serialized); + expect(deserialized).toBeTruthy(); + expect(deserialized.version).toBe(4); + expect(deserialized.content).toEqual(state.content); + }); + + it('should compress large states when beneficial', () => { + const state = { + content: [ + { + type: 'row', + content: Array.from({length: 50}, (_, i) => ({ + type: 'component', + componentName: 'codeEditor', + componentState: { + id: i, + lang: 'c++', + source: 'int main() { return 0; }', + }, + })), + }, + ], + }; + + const serialized = urlSerialization.serialiseState(state); + // Compressed format contains {z: ...} which rison-encodes to contain 'z:' + // This test just verifies the state serializes successfully + expect(serialized).toBeTruthy(); + expect(typeof serialized).toBe('string'); + expect(serialized.length).toBeGreaterThan(0); + + // Verify it can be deserialized + const deserialized = urlSerialization.deserialiseState(serialized); + expect(deserialized.content).toHaveLength(1); + expect(deserialized.content[0].content).toHaveLength(50); + }); + + it('should handle round-trip encoding correctly', () => { + const state = { + content: [ + { + type: 'column', + content: [ + { + type: 'stack', + content: [ + { + type: 'component', + componentName: 'compiler', + componentState: { + id: 1, + compiler: 'g132', + }, + }, + ], + }, + ], + }, + ], + }; + + const hash1 = urlSerialization.serialiseState(state); + const restored = urlSerialization.deserialiseState(hash1); + const hash2 = urlSerialization.serialiseState(restored); + + // Round-trip should produce identical hash + expect(hash2).toBe(hash1); + }); +});