mirror of
https://github.com/compiler-explorer/compiler-explorer.git
synced 2025-12-27 07:04:04 -05:00
URL serialization refactoring and Cypress test improvements (#8215)
## Summary This PR makes URL serialization logic available to Node.js contexts (like Cypress tests) and replaces a hard-coded 4812-character base64 URL in tests with programmatically generated state. This builds on the shared utilities refactoring from #8246. ### Changes #### 1. Extract URL Serialization to Shared Module **Problem:** URL serialization code depended on GoldenLayout's browser-only ConfigMinifier, preventing Cypress spec files from importing it (they load in Node.js before running in browser). **Solution:** Created `shared/url-serialization.ts` with a Node-compatible ConfigMinifier reimplementation. **Technical Details:** - Reimplemented GoldenLayout's ConfigMinifier without browser dependencies - Moved serialization functions (`serialiseState`, `deserialiseState`, `risonify`, `unrisonify`) to shared module - Moved minification functions (`minifyConfig`, `unminifyConfig`) to shared module - Updated `static/url.ts` to use shared module instead of GoldenLayout - Added comprehensive test coverage in `test/url-serialization.ts` **Files:** - **New:** `shared/url-serialization.ts` (~279 lines) - **Modified:** `static/url.ts` (removed ~30 lines, eliminated GoldenLayout dependency) - **New:** `test/url-serialization.ts` (~96 lines) #### 2. Replace Hard-coded Cypress URL with Programmatic State **Before:** A hard-coded 4812-character base64 URL containing state for all panes ```typescript cy.visit('http://localhost:10240/#z:OYLghAFBqd5TB8IAsQGMD2ATApgUWwEsAXTAJwBoiQIAzIgG...'); ``` **After:** Programmatically generated state using `buildKnownGoodState()` function ```typescript const state = buildKnownGoodState(); const hash = serialiseState(state); cy.visit(`http://localhost:10240/#${hash}`, {...}); ``` **Benefits:** - Human-readable, maintainable test state - Programmatic generation from `PANE_DATA_MAP` keys - Layout optimized with 8 panes per row - Produces identical compressed URL format - Much easier to add/modify panes in the future #### 3. PANE_DATA_MAP Consistency Improvements Updated `PANE_DATA_MAP` to use component names exactly as registered with GoldenLayout: **Key renames:** - `preprocessor` → `pp` - `llvmir` → `ir` - `pipeline` → `llvmOptPipelineView` - `mir` → `rustmir` - `hir` → `rusthir` - `macro` → `rustmacroexp` - `core` → `haskellCore` - `stg` → `haskellStg` - `cmm` → `haskellCmm` - `dump` → `gccdump` - `tree` → `gnatdebugtree` - `debug` → `gnatdebug` **Added panes:** `codeEditor`, `compiler`, `conformance`, `output` (were missing from map) **Re-enabled tests:** - `yul` pane test (was commented out, now fixed) - `clojuremacroexp` pane test (was commented out, now fixed) - `cfg` pane test (had TODO, now removed) **Why this matters:** The `buildKnownGoodState()` function uses `Object.keys(PANE_DATA_MAP)` as the `componentName` property, so keys must match the actual registered component names for GoldenLayout to find them. ## Test Plan - [x] All Cypress tests pass (confirmed by @mattgodbolt) - [x] TypeScript compilation passes (`npm run ts-check`) - [x] Linting passes (`npm run lint`) - [x] URL serialization tests pass (3/3 tests) - [x] Pre-commit hooks pass - [x] Related vitest tests pass ## Dependencies - Builds on #8246 (shared utilities refactoring - already merged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, any> = {
|
||||
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',
|
||||
{
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
//
|
||||
|
||||
279
shared/url-serialization.ts
Normal file
279
shared/url-serialization.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
'<div style="padding: 10px; background: #fa564e; color: black;">' +
|
||||
@@ -285,7 +286,7 @@ function configFromEmbedded(embeddedUrl: string, defaultLangId: string) {
|
||||
],
|
||||
};
|
||||
}
|
||||
return url.deserialiseState(embeddedUrl);
|
||||
return deserialiseState(embeddedUrl);
|
||||
}
|
||||
|
||||
function fixBugsInConfig(config: Partial<GoldenLayout.Config & {activeItemIndex?: number}>): 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, boolean> = {};
|
||||
@@ -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 {
|
||||
|
||||
224
static/tests/url-tests.ts
Normal file
224
static/tests/url-tests.ts
Normal file
@@ -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 <xmmintrin.h>');
|
||||
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 <vector>');
|
||||
expect(editor.componentState.source).toContain('struct Widget');
|
||||
expect(editor.componentState.source).toContain('std::vector<Widget> 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, UserSiteTemplate> = JSON.parse(
|
||||
|
||||
96
test/url-serialization.ts
Normal file
96
test/url-serialization.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user