Files
compiler-explorer/static/sharing.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

530 lines
20 KiB
TypeScript

// Copyright (c) 2021, 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 {Modal, Tooltip} from 'bootstrap';
import ClipboardJS from 'clipboard';
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 ClickEvent = JQuery.ClickEvent;
import cloneDeep from 'lodash.clonedeep';
enum LinkType {
Short = 0,
Full = 1,
Embed = 2,
}
const shareServices = {
twitter: {
embedValid: false,
logoClass: 'fab fa-twitter',
cssClass: 'share-twitter',
getLink: (title: string, url: string) => {
return (
'https://twitter.com/intent/tweet' +
`?text=${encodeURIComponent(title)}` +
`&url=${encodeURIComponent(url)}` +
'&via=CompileExplore'
);
},
text: 'Tweet',
},
bluesky: {
embedValid: false,
logoClass: 'fab fa-bluesky',
cssClass: 'share-bluesky',
getLink: (title: string, url: string) => {
const text = `${title} ${url} via @compiler-explorer.com`;
return `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`;
},
text: 'Share on Bluesky',
},
reddit: {
embedValid: false,
logoClass: 'fab fa-reddit',
cssClass: 'share-reddit',
getLink: (title: string, url: string) => {
return (
'http://www.reddit.com/submit' +
`?url=${encodeURIComponent(url)}` +
`&title=${encodeURIComponent(title)}`
);
},
text: 'Share on Reddit',
},
};
// Base class that handles state tracking and embedded link updates
export class SharingBase {
protected layout: GoldenLayout;
protected lastState: string | null = null;
constructor(layout: GoldenLayout) {
this.layout = layout;
this.initCallbacks();
}
protected initCallbacks(): void {
this.layout.on('stateChanged', this.onStateChanged.bind(this));
}
protected onStateChanged(): void {
const config = SharingBase.filterComponentState(this.layout.toConfig());
this.ensureUrlIsNotOutdated(config);
// 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 + '#' + serialiseState(config));
}
}
protected ensureUrlIsNotOutdated(config: any): void {
const stringifiedConfig = JSON.stringify(config);
if (stringifiedConfig !== this.lastState) {
if (this.lastState != null && window.location.pathname !== window.httpRoot) {
window.history.replaceState(null, '', window.httpRoot);
}
this.lastState = stringifiedConfig;
}
}
public static filterComponentState(config: any): any {
function filterComponentStateImpl(component: any) {
if (component.content) {
for (let i = 0; i < component.content.length; i++) {
filterComponentStateImpl(component.content[i]);
}
}
if (component.componentState) {
delete component.componentState.selection;
}
}
config = cloneDeep(config);
filterComponentStateImpl(config);
return config;
}
}
export class Sharing extends SharingBase {
private readonly share: JQuery;
private readonly shareTooltipTarget: JQuery;
private readonly shareShort: JQuery;
private readonly shareFull: JQuery;
private readonly shareEmbed: JQuery;
private settings: SiteSettings;
private clippyButton: ClipboardJS | null;
private readonly shareLinkDialog: HTMLElement;
constructor(layout: GoldenLayout) {
super(layout);
this.shareLinkDialog = unwrap(document.getElementById('sharelinkdialog'), 'Share modal element not found');
this.share = $('#share');
this.shareTooltipTarget = $('#share-tooltip-target');
this.shareShort = $('#shareShort');
this.shareFull = $('#shareFull');
this.shareEmbed = $('#shareEmbed');
[this.shareShort, this.shareFull, this.shareEmbed].forEach(el => {
el.on('click', e => BootstrapUtils.showModal(this.shareLinkDialog, e.currentTarget));
});
this.settings = Settings.getStoredSettings();
this.clippyButton = null;
this.initButtons();
this.initFullCallbacks();
}
private initFullCallbacks(): void {
this.layout.eventHub.on('displaySharingPopover', () => {
this.openShareModalForType(LinkType.Short);
});
this.layout.eventHub.on('copyShortLinkToClip', () => {
this.copyLinkTypeToClipboard(LinkType.Short);
});
this.shareLinkDialog.addEventListener('show.bs.modal', this.onOpenModalPane.bind(this));
this.shareLinkDialog.addEventListener('hidden.bs.modal', this.onCloseModalPane.bind(this));
this.layout.eventHub.on('settingsChange', (newSettings: SiteSettings) => {
this.settings = newSettings;
});
$(window).on('blur', async () => {
sessionThenLocalStorage.set('gl', JSON.stringify(this.layout.toConfig()));
if (this.settings.keepMultipleTabs) {
try {
const link = await this.getLinkOfType(LinkType.Full);
window.history.replaceState(null, '', link);
} catch (e) {
// This is probably caused by a link that is too long
SentryCapture(e, 'url update');
}
}
});
}
private static bindToLinkType(bind: string): LinkType {
switch (bind) {
case 'Full':
return LinkType.Full;
case 'Short':
return LinkType.Short;
case 'Embed':
return LinkType.Embed;
default:
return LinkType.Full;
}
}
private onOpenModalPane(event: Event): void {
const modalEvent = event as Modal.Event;
if (!modalEvent.relatedTarget) {
throw new Error('No relatedTarget found in modal event');
}
const button = $(modalEvent.relatedTarget);
const bindStr = button.data('bind') as string;
const currentBind = Sharing.bindToLinkType(bindStr);
const modal = $(event.currentTarget as HTMLElement);
const socialSharingElements = modal.find('.socialsharing');
const permalink = modal.find('.permalink');
const embedsettings = modal.find('#embedsettings');
const clipboardButton = modal.find('.clippy');
const updatePermaLink = () => {
socialSharingElements.empty();
const config = this.layout.toConfig();
Sharing.getLinks(config, currentBind, (error: any, newUrl: string, extra: string, updateState: boolean) => {
permalink.off('click');
if (error || !newUrl) {
clipboardButton.prop('disabled', true);
permalink.val(error || 'Error providing URL');
SentryCapture(error, 'Error providing url');
} else {
if (updateState) {
Sharing.storeCurrentConfig(config, extra);
}
clipboardButton.prop('disabled', false);
permalink.val(newUrl);
permalink.on('click', () => {
permalink.trigger('focus').trigger('select');
});
if (options.sharingEnabled) {
Sharing.updateShares(socialSharingElements, newUrl);
// Disable the links for every share item which does not support embed html as links
if (currentBind === LinkType.Embed) {
socialSharingElements.children('.share-no-embeddable').hide().on('click', false);
}
}
}
});
};
const clippyElement = modal.find('button.clippy').get(0);
if (clippyElement != null) {
this.clippyButton = new ClipboardJS(clippyElement);
this.clippyButton.on('success', e => {
this.displayTooltip(permalink, 'Link copied to clipboard');
e.clearSelection();
});
this.clippyButton.on('error', _e => {
this.displayTooltip(permalink, 'Error copying to clipboard');
});
}
if (currentBind === LinkType.Embed) {
embedsettings.show();
embedsettings
.find('input')
// Off any prev click handlers to avoid multiple events triggering after opening the modal more than once
.off('click')
.on('click', () => updatePermaLink());
} else {
embedsettings.hide();
}
updatePermaLink();
}
private onCloseModalPane(): void {
if (this.clippyButton) {
this.clippyButton.destroy();
this.clippyButton = null;
}
}
private initButtons(): void {
const shareShortCopyToClipBtn = this.shareShort.find('.clip-icon');
const shareFullCopyToClipBtn = this.shareFull.find('.clip-icon');
const shareEmbedCopyToClipBtn = this.shareEmbed.find('.clip-icon');
shareShortCopyToClipBtn.on('click', e => this.onClipButtonPressed(e, LinkType.Short));
shareFullCopyToClipBtn.on('click', e => this.onClipButtonPressed(e, LinkType.Full));
shareEmbedCopyToClipBtn.on('click', e => this.onClipButtonPressed(e, LinkType.Embed));
if (options.sharingEnabled) {
Sharing.updateShares($('#socialshare'), window.location.protocol + '//' + window.location.hostname);
}
}
private onClipButtonPressed(event: ClickEvent, type: LinkType): boolean {
// Don't let the modal show up.
// We need this because the button is a child of the dropdown-item with a data-bs-toggle=modal
if (Sharing.isNavigatorClipboardAvailable()) {
this.copyLinkTypeToClipboard(type);
event.stopPropagation();
// As we prevented bubbling, the dropdown won't close by itself.
BootstrapUtils.hideDropdown(this.share);
}
return false;
}
private getLinkOfType(type: LinkType): Promise<string> {
const config = this.layout.toConfig();
return new Promise<string>((resolve, reject) => {
Sharing.getLinks(config, type, (error: any, newUrl: string, extra: string, updateState: boolean) => {
if (error || !newUrl) {
this.displayTooltip(this.shareTooltipTarget, 'Oops, something went wrong');
SentryCapture(error, 'Getting short link failed');
reject(
new Error(
error
? `Getting short link failed: ${error}`
: 'Getting short link failed: no URL returned',
),
);
} else {
if (updateState) {
Sharing.storeCurrentConfig(config, extra);
}
resolve(newUrl);
}
});
});
}
private copyLinkTypeToClipboard(type: LinkType): void {
const config = this.layout.toConfig();
Sharing.getLinks(config, type, (error: any, newUrl: string, extra: string, updateState: boolean) => {
if (error || !newUrl) {
this.displayTooltip(this.shareTooltipTarget, 'Oops, something went wrong');
SentryCapture(error, 'Getting short link failed');
} else {
if (updateState) {
Sharing.storeCurrentConfig(config, extra);
}
this.doLinkCopyToClipboard(type, newUrl);
}
});
}
// TODO we can consider using bootstrap's "Toast" support in future.
private displayTooltip(where: JQuery, message: string): void {
// First dispose any existing tooltip
const tooltipEl = where[0];
if (!tooltipEl) return;
const existingTooltip = Tooltip.getInstance(tooltipEl);
if (existingTooltip) {
existingTooltip.dispose();
}
// Create and show new tooltip
try {
const tooltip = new Tooltip(tooltipEl, {
placement: 'bottom',
trigger: 'manual',
title: message,
});
tooltip.show();
// Manual triggering of tooltips does not hide them automatically. This timeout ensures they do
setTimeout(() => tooltip.hide(), 1500);
} catch (e) {
// If element doesn't exist, just silently fail
console.warn('Could not show tooltip:', e);
}
}
private openShareModalForType(type: LinkType): void {
switch (type) {
case LinkType.Short:
this.shareShort.trigger('click');
break;
case LinkType.Full:
this.shareFull.trigger('click');
break;
case LinkType.Embed:
this.shareEmbed.trigger('click');
break;
}
}
private doLinkCopyToClipboard(type: LinkType, link: string): void {
if (Sharing.isNavigatorClipboardAvailable()) {
navigator.clipboard
.writeText(link)
.then(() => this.displayTooltip(this.shareTooltipTarget, 'Link copied to clipboard'))
.catch(() => this.openShareModalForType(type));
} else {
this.openShareModalForType(type);
}
}
public static getLinks(config: any, currentBind: LinkType, done: CallableFunction): void {
const root = window.httpRoot;
switch (currentBind) {
case LinkType.Short:
Sharing.getShortLink(config, root, done);
return;
case LinkType.Full:
done(null, window.location.origin + root + '#' + serialiseState(config), false);
return;
case LinkType.Embed: {
const options: Record<string, boolean> = {};
$('#sharelinkdialog input:checked').each((i, element) => {
options[$(element).data('option')] = true;
});
done(null, Sharing.getEmbeddedHtml(config, root, false, options), false);
return;
}
default:
// Hmmm
done('Unknown link type', null);
}
}
private static getShortLink(config: any, root: string, done: CallableFunction): void {
const useExternalShortener = options.urlShortenService !== 'default';
const data = JSON.stringify({
config: useExternalShortener ? serialiseState(config) : config,
});
$.ajax({
type: 'POST',
url: window.location.origin + root + 'api/shortener',
dataType: 'json', // Expected
contentType: 'application/json', // Sent
data: data,
success: (result: any) => {
const pushState = useExternalShortener ? null : result.url;
done(null, result.url, pushState, true);
},
error: err => {
// Notify the user that we ran into trouble?
done(err.statusText, null, false);
},
cache: true,
});
}
private static getEmbeddedHtml(
config: any,
root: string,
isReadOnly: boolean,
extraOptions: Record<string, boolean>,
): string {
const embedUrl = Sharing.getEmbeddedUrl(config, root, isReadOnly, extraOptions);
// The attributes must be double quoted, the full url's rison contains single quotes
return `<iframe width="800px" height="200px" src="${embedUrl}"></iframe>`;
}
private static getEmbeddedUrl(config: any, root: string, readOnly: boolean, extraOptions: object): string {
const location = window.location.origin + root;
const parameters = _.reduce(
extraOptions,
(total, value, key): string => {
if (total === '') {
total = '?';
} else {
total += '&';
}
return total + key + '=' + value;
},
'',
);
const path = (readOnly ? 'embed-ro' : 'e') + parameters + '#';
return location + path + serialiseState(config);
}
private static storeCurrentConfig(config: any, extra: string): void {
window.history.pushState(null, '', extra);
}
private static isNavigatorClipboardAvailable(): boolean {
return (navigator.clipboard as Clipboard | undefined) !== undefined;
}
private static updateShares(container: JQuery, url: string): void {
const baseTemplate = $('#share-item');
_.each(shareServices, (service, serviceName) => {
const newElement = baseTemplate.children('a.share-item').clone();
if (service.logoClass) {
newElement.prepend(
$('<span>').addClass('dropdown-icon me-1').addClass(service.logoClass).prop('title', serviceName),
);
}
if (service.text) {
newElement.children('span.share-item-text').text(service.text);
}
newElement
.prop('href', service.getLink('Compiler Explorer', url))
.addClass(service.cssClass)
.toggleClass('share-no-embeddable', !service.embedValid)
.appendTo(container);
});
}
}
// Initialize sharing functionality based on whether we're in embedded mode or not
export function initialiseSharing(layout: GoldenLayout, isEmbedded: boolean): void {
if (isEmbedded) {
// Just create the base class for state tracking and link updates
new SharingBase(layout);
} else {
// Create full Sharing with all features
new Sharing(layout);
}
}