Files
compiler-explorer/shared/url-serialization.ts
Matt Godbolt 65e4f302b7 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>
2025-11-04 14:09:01 -06:00

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;
}