mirror of
https://github.com/compiler-explorer/compiler-explorer.git
synced 2025-12-27 07:04:04 -05:00
## 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>
280 lines
9.0 KiB
TypeScript
280 lines
9.0 KiB
TypeScript
// 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;
|
|
}
|