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:
Matt Godbolt
2025-11-04 14:09:01 -06:00
committed by GitHub
parent f7bc52a923
commit 65e4f302b7
11 changed files with 739 additions and 86 deletions

View File

@@ -1,27 +1,30 @@
import {serialiseState} from '../../shared/url-serialization.js';
import {assertNoConsoleOutput, stubConsoleOutput} from '../support/utils'; import {assertNoConsoleOutput, stubConsoleOutput} from '../support/utils';
const PANE_DATA_MAP = { 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'}, executor: {name: 'Executor', selector: 'create-executor'},
opt: {name: 'Opt Viewer', selector: 'view-optimization'}, opt: {name: 'Opt Viewer', selector: 'view-optimization'},
stackusage: {name: 'Stack Usage Viewer', selector: 'view-stack-usage'}, 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'}, ast: {name: 'Ast Viewer', selector: 'view-ast'},
llvmir: {name: 'LLVM IR', selector: 'view-ir'}, ir: {name: 'LLVM IR', selector: 'view-ir'},
pipeline: {name: 'Pipeline', selector: 'view-opt-pipeline'}, llvmOptPipelineView: {name: 'Pipeline', selector: 'view-opt-pipeline'},
device: {name: 'Device', selector: 'view-device'}, device: {name: 'Device', selector: 'view-device'},
mir: {name: 'MIR', selector: 'view-rustmir'}, rustmir: {name: 'MIR', selector: 'view-rustmir'},
hir: {name: 'HIR', selector: 'view-rusthir'}, rusthir: {name: 'HIR', selector: 'view-rusthir'},
macro: {name: 'Macro', selector: 'view-rustmacroexp'}, rustmacroexp: {name: 'Macro', selector: 'view-rustmacroexp'},
core: {name: 'Core', selector: 'view-haskellCore'}, haskellCore: {name: 'Core', selector: 'view-haskellCore'},
stg: {name: 'STG', selector: 'view-haskellStg'}, haskellStg: {name: 'STG', selector: 'view-haskellStg'},
cmm: {name: 'Cmm', selector: 'view-haskellCmm'}, haskellCmm: {name: 'Cmm', selector: 'view-haskellCmm'},
// TODO find a way to properly hack the state URL to test these panes like the rust yul: {name: 'Yul', selector: 'view-yul'},
// ones seem to be able to do. clojuremacroexp: {name: 'Clojure Macro', selector: 'view-clojuremacroexp'},
// clojure_macro: {name: 'Clojure Macro', selector: 'view-clojuremacroexp'}, gccdump: {name: 'Tree/RTL', selector: 'view-gccdump'},
// yul: {name: 'Yul', selector: 'view-yul'}, gnatdebugtree: {name: 'Tree', selector: 'view-gnatdebugtree'},
dump: {name: 'Tree/RTL', selector: 'view-gccdump'}, gnatdebug: {name: 'Debug', selector: 'view-gnatdebug'},
tree: {name: 'Tree', selector: 'view-gnatdebugtree'},
debug: {name: 'Debug', selector: 'view-gnatdebug'},
cfg: {name: 'CFG', selector: 'view-cfg'}, cfg: {name: 'CFG', selector: 'view-cfg'},
explain: {name: 'Claude Explain', selector: 'view-explain'}, explain: {name: 'Claude Explain', selector: 'view-explain'},
}; };
@@ -58,26 +61,25 @@ describe('Individual pane testing', () => {
addPaneOpenTest(PANE_DATA_MAP.executor); addPaneOpenTest(PANE_DATA_MAP.executor);
addPaneOpenTest(PANE_DATA_MAP.opt); addPaneOpenTest(PANE_DATA_MAP.opt);
addPaneOpenTest(PANE_DATA_MAP.preprocessor); addPaneOpenTest(PANE_DATA_MAP.pp);
addPaneOpenTest(PANE_DATA_MAP.ast); addPaneOpenTest(PANE_DATA_MAP.ast);
addPaneOpenTest(PANE_DATA_MAP.llvmir); addPaneOpenTest(PANE_DATA_MAP.ir);
addPaneOpenTest(PANE_DATA_MAP.pipeline); addPaneOpenTest(PANE_DATA_MAP.llvmOptPipelineView);
// TODO: re-enable this when fixed addPaneOpenTest(PANE_DATA_MAP.device); addPaneOpenTest(PANE_DATA_MAP.device);
addPaneOpenTest(PANE_DATA_MAP.mir); addPaneOpenTest(PANE_DATA_MAP.rustmir);
addPaneOpenTest(PANE_DATA_MAP.hir); addPaneOpenTest(PANE_DATA_MAP.rusthir);
addPaneOpenTest(PANE_DATA_MAP.macro); addPaneOpenTest(PANE_DATA_MAP.rustmacroexp);
addPaneOpenTest(PANE_DATA_MAP.core); addPaneOpenTest(PANE_DATA_MAP.haskellCore);
addPaneOpenTest(PANE_DATA_MAP.stg); addPaneOpenTest(PANE_DATA_MAP.haskellStg);
addPaneOpenTest(PANE_DATA_MAP.cmm); addPaneOpenTest(PANE_DATA_MAP.haskellCmm);
// TODO: bring back once #8215 lands addPaneOpenTest(PANE_DATA_MAP.yul);
// addPaneOpenTest(PANE_DATA_MAP.yul); addPaneOpenTest(PANE_DATA_MAP.clojuremacroexp);
addPaneOpenTest(PANE_DATA_MAP.dump); addPaneOpenTest(PANE_DATA_MAP.gccdump);
addPaneOpenTest(PANE_DATA_MAP.tree); addPaneOpenTest(PANE_DATA_MAP.gnatdebugtree);
addPaneOpenTest(PANE_DATA_MAP.debug); addPaneOpenTest(PANE_DATA_MAP.gnatdebug);
addPaneOpenTest(PANE_DATA_MAP.stackusage); addPaneOpenTest(PANE_DATA_MAP.stackusage);
addPaneOpenTest(PANE_DATA_MAP.explain); addPaneOpenTest(PANE_DATA_MAP.explain);
// TODO: Bring back once #3899 lands addPaneOpenTest(PANE_DATA_MAP.cfg);
// addPaneOpenTest(PaneDataMap.cfg);
it('Output pane', () => { it('Output pane', () => {
// Hide the dropdown // 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-compiler-dropdown-btn"]:visible').click();
cy.get('[data-cy="new-editor-dropdown-btn"]:visible').click(); cy.get('[data-cy="new-editor-dropdown-btn"]:visible').click();
cy.get('[data-cy="new-conformance-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', () => { describe('Known good state test', () => {
beforeEach(() => { beforeEach(() => {
cy.visit( const state = buildKnownGoodState();
// This URL manually created. If you need to update, run a local server like the docs/UsingCypress.md say, const hash = serialiseState(state);
// then paste this into your browser, make the changes, and then re-share as a full link. cy.visit(`http://localhost:10240/#${hash}`, {
'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);
onBeforeLoad: win => {
stubConsoleOutput(win);
},
}, },
); });
}); });
afterEach(() => { afterEach(() => {
@@ -120,8 +197,5 @@ describe('Known good state test', () => {
const pane = PANE_DATA_MAP[paneId]; const pane = PANE_DATA_MAP[paneId];
cy.get('span.lm_title:visible').contains(pane.name); 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');
}); });
}); });

View File

@@ -22,8 +22,8 @@
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE. // POSSIBILITY OF SUCH DAMAGE.
import {isString} from '../shared/common-utils.js'; import {isString} from './common-utils.js';
import {parse} from '../shared/stacktrace.js'; import {parse} from './stacktrace.js';
// This file defines three assert utilities: // This file defines three assert utilities:
// assert(condition, message?, extra_info...?): asserts condition // assert(condition, message?, extra_info...?): asserts condition

View File

@@ -1,7 +1,7 @@
// Based on https://github.com/Nanonid/rison at e64af6c096fd30950ec32cfd48526ca6ee21649d (Jun 9, 2017) // 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 {assert, unwrap} from './assert.js';
import {isString} from './common-utils.js';
////////////////////////////////////////////////// //////////////////////////////////////////////////
// //

279
shared/url-serialization.ts Normal file
View 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;
}

View File

@@ -45,6 +45,7 @@ let jsCookie = JsCookie;
import {unwrap} from '../shared/assert.js'; import {unwrap} from '../shared/assert.js';
import * as utils from '../shared/common-utils.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 {ParseFiltersAndOutputOptions} from '../types/features/filters.interfaces.js';
import {LanguageKey} from '../types/languages.interfaces.js'; import {LanguageKey} from '../types/languages.interfaces.js';
import * as BootstrapUtils from './bootstrap-utils.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 {Settings, SiteSettings} from './settings.js';
import {initialiseSharing} from './sharing.js'; import {initialiseSharing} from './sharing.js';
import {Themer} from './themes.js'; import {Themer} from './themes.js';
import * as url from './url.js'; import {deserialiseState} from './url.js';
import {formatISODate, updateAndCalcTopBarHeight} from './utils.js'; import {formatISODate, updateAndCalcTopBarHeight} from './utils.js';
import {Alert} from './widgets/alert.js'; import {Alert} from './widgets/alert.js';
import {HistoryWidget} from './widgets/history-widget.js'; import {HistoryWidget} from './widgets/history-widget.js';
@@ -256,7 +257,7 @@ function configFromEmbedded(embeddedUrl: string, defaultLangId: string) {
// Old-style link? // Old-style link?
let params; let params;
try { try {
params = url.unrisonify(embeddedUrl); params = unrisonify(embeddedUrl);
} catch { } catch {
document.write( document.write(
'<div style="padding: 10px; background: #fa564e; color: black;">' + '<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 { function fixBugsInConfig(config: Partial<GoldenLayout.Config & {activeItemIndex?: number}>): void {
@@ -332,7 +333,7 @@ function findConfig(
config = options.config; config = options.config;
} else { } else {
try { try {
config = url.deserialiseState(window.location.hash.substring(1)); config = deserialiseState(window.location.hash.substring(1));
} catch { } catch {
// #3518 Alert the user that the url is invalid // #3518 Alert the user that the url is invalid
const alertSystem = new Alert(); const alertSystem = new Alert();

View File

@@ -25,9 +25,9 @@
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import GoldenLayout from 'golden-layout'; import GoldenLayout from 'golden-layout';
import {parse} from '../shared/stacktrace.js'; import {parse} from '../shared/stacktrace.js';
import {serialiseState} from '../shared/url-serialization.js';
import {options} from './options.js'; import {options} from './options.js';
import {SiteSettings} from './settings.js'; import {SiteSettings} from './settings.js';
import {serialiseState} from './url.js';
let layout: GoldenLayout; let layout: GoldenLayout;
let allowSendCode: boolean; let allowSendCode: boolean;

View File

@@ -28,12 +28,12 @@ import GoldenLayout from 'golden-layout';
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import {unwrap} from '../shared/assert.js'; import {unwrap} from '../shared/assert.js';
import {serialiseState} from '../shared/url-serialization.js';
import * as BootstrapUtils from './bootstrap-utils.js'; import * as BootstrapUtils from './bootstrap-utils.js';
import {sessionThenLocalStorage} from './local.js'; import {sessionThenLocalStorage} from './local.js';
import {options} from './options.js'; import {options} from './options.js';
import {SentryCapture} from './sentry.js'; import {SentryCapture} from './sentry.js';
import {Settings, SiteSettings} from './settings.js'; import {Settings, SiteSettings} from './settings.js';
import * as url from './url.js';
import ClickEvent = JQuery.ClickEvent; import ClickEvent = JQuery.ClickEvent;
@@ -106,7 +106,7 @@ export class SharingBase {
// Update embedded links if present (works in both modes) // Update embedded links if present (works in both modes)
if (options.embedded) { if (options.embedded) {
const strippedToLast = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1); 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); Sharing.getShortLink(config, root, done);
return; return;
case LinkType.Full: case LinkType.Full:
done(null, window.location.origin + root + '#' + url.serialiseState(config), false); done(null, window.location.origin + root + '#' + serialiseState(config), false);
return; return;
case LinkType.Embed: { case LinkType.Embed: {
const options: Record<string, boolean> = {}; const options: Record<string, boolean> = {};
@@ -436,7 +436,7 @@ export class Sharing extends SharingBase {
private static getShortLink(config: any, root: string, done: CallableFunction): void { private static getShortLink(config: any, root: string, done: CallableFunction): void {
const useExternalShortener = options.urlShortenService !== 'default'; const useExternalShortener = options.urlShortenService !== 'default';
const data = JSON.stringify({ const data = JSON.stringify({
config: useExternalShortener ? url.serialiseState(config) : config, config: useExternalShortener ? serialiseState(config) : config,
}); });
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -485,7 +485,7 @@ export class Sharing extends SharingBase {
const path = (readOnly ? 'embed-ro' : 'e') + parameters + '#'; 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 { private static storeCurrentConfig(config: any, extra: string): void {

224
static/tests/url-tests.ts Normal file
View 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();
});
});
});

View File

@@ -22,10 +22,9 @@
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE. // POSSIBILITY OF SUCH DAMAGE.
import GoldenLayout from 'golden-layout';
import lzstring from 'lz-string'; import lzstring from 'lz-string';
import _ from 'underscore'; import _ from 'underscore';
import * as rison from '../shared/rison.js'; import * as urlSerialization from '../shared/url-serialization.js';
import * as Components from './components.js'; import * as Components from './components.js';
export function convertOldState(state: any): any { export function convertOldState(state: any): any {
@@ -67,7 +66,7 @@ export function loadState(state: any): any {
state = convertOldState(state); state = convertOldState(state);
break; // no fall through break; // no fall through
case 4: case 4:
state = GoldenLayout.unminifyConfig(state); state = urlSerialization.unminifyConfig(state);
break; break;
default: default:
throw new Error("Invalid version '" + state.version + "'"); throw new Error("Invalid version '" + state.version + "'");
@@ -75,26 +74,18 @@ export function loadState(state: any): any {
return state; 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 { export function deserialiseState(stateText: string): any {
let state; let state;
let exception; let exception;
try { try {
state = unrisonify(stateText); state = urlSerialization.unrisonify(stateText);
if (state?.z) { if (state?.z) {
const data = lzstring.decompressFromBase64(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 lzstring fails to decompress this it'll return an empty string rather than throwing an error
if (data === '') { if (data === '') {
throw new Error('lzstring decompress error, url is corrupted'); throw new Error('lzstring decompress error, url is corrupted');
} }
state = unrisonify(data); state = urlSerialization.unrisonify(data);
} }
} catch (ex) { } catch (ex) {
exception = ex; exception = ex;
@@ -112,15 +103,3 @@ export function deserialiseState(stateText: string): any {
if (exception) throw exception; if (exception) throw exception;
return loadState(state); 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;
}

View File

@@ -26,11 +26,11 @@ import GoldenLayout from 'golden-layout';
import $ from 'jquery'; import $ from 'jquery';
import {assert, unwrap, unwrapString} from '../../shared/assert.js'; import {assert, unwrap, unwrapString} from '../../shared/assert.js';
import {escapeHTML} from '../../shared/common-utils.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 {SiteTemplateConfiguration, UserSiteTemplate} from '../../types/features/site-templates.interfaces.js';
import * as BootstrapUtils from '../bootstrap-utils.js'; import * as BootstrapUtils from '../bootstrap-utils.js';
import {localStorage} from '../local.js'; import {localStorage} from '../local.js';
import {Settings} from '../settings.js'; import {Settings} from '../settings.js';
import * as url from '../url.js';
import {getStaticImage} from '../utils'; import {getStaticImage} from '../utils';
import {Alert} from './alert.js'; import {Alert} from './alert.js';
@@ -54,7 +54,7 @@ class SiteTemplatesWidget {
} }
saveCurrentAsTemplate() { saveCurrentAsTemplate() {
const config = this.layout.toConfig(); const config = this.layout.toConfig();
const data = url.serialiseState(config); const data = serialiseState(config);
this.alertSystem.enterSomething('Template Name', '', '', { this.alertSystem.enterSomething('Template Name', '', '', {
yes: name => { yes: name => {
const userTemplates: Record<string, UserSiteTemplate> = JSON.parse( const userTemplates: Record<string, UserSiteTemplate> = JSON.parse(

96
test/url-serialization.ts Normal file
View 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);
});
});