Files
compiler-explorer/static/panes/explain-view.ts
Matt Godbolt 50ec53d0e7 Add comprehensive Cypress E2E tests for Claude Explain feature (#7751)
- Add comprehensive test suite covering all Claude Explain functionality:
  - Basic pane opening and consent flow
  - no-ai directive detection
  - API interactions and error handling
  - Options/customization features
  - Caching behavior and persistence
  - Compilation state handling
  - State persistence across page loads

- Fix caching bug in explain-view.ts:
  - Cache was incorrectly implemented as instance variable, losing cached explanations when panes were closed/reopened
  - Made cache static to persist across all pane instances (matches consent persistence pattern)
  - Fixes failing "Caching and reload" Cypress test
  - Aligns implementation with documented behavior: "shared across all explain views in the session"

- Add test utilities and helpers:
  - Monaco editor content manipulation using clipboard events
  - Claude Explain specific helpers moved to test file
  - General utilities remain in utils.ts

- Performance optimizations:
  - Clear Cypress intercepts in afterEach to prevent O(n²) degradation
  - Use :visible selectors to avoid GoldenLayout template elements
  - Proper mock setup timing to prevent race conditions

- Add comprehensive README with lessons learned and best practices

All tests use fake test data (test_first, focus_a, etc.) to clearly distinguish from production values and prevent accidental API calls.
2025-08-05 16:42:48 -05:00

551 lines
20 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 {Container} from 'golden-layout';
import $ from 'jquery';
import {LRUCache} from 'lru-cache';
import {capitaliseFirst} from '../../shared/common-utils.js';
import {CompilationResult} from '../../types/compilation/compilation.interfaces.js';
import {CompilerInfo} from '../../types/compiler.interfaces.js';
import {initPopover} from '../bootstrap-utils.js';
import {Hub} from '../hub.js';
import {options} from '../options.js';
import {SentryCapture} from '../sentry.js';
import * as utils from '../utils.js';
import {FontScale} from '../widgets/fontscale.js';
import {AvailableOptions, ClaudeExplainResponse, ExplainRequest, ExplainViewState} from './explain-view.interfaces.js';
import {
buildExplainRequest,
checkForNoAiDirective,
createPopoverContent,
ExplainContext,
formatErrorMessage,
formatMarkdown,
formatStatsText,
generateCacheKey,
ValidationErrorCode,
validateExplainPreconditions,
} from './explain-view-utils.js';
import {Pane} from './pane.js';
enum StatusIconState {
Loading = 'loading',
Success = 'success',
Error = 'error',
Hidden = 'hidden',
}
const defaultAudienceType = 'beginner';
const defaultExplanationType = 'assembly';
const statusIconConfigs = {
[StatusIconState.Loading]: {
classes: 'status-icon fas fa-spinner fa-spin',
color: '',
ariaLabel: 'Generating explanation...',
},
[StatusIconState.Success]: {
classes: 'status-icon fas fa-check-circle',
color: '#4CAF50',
ariaLabel: 'Explanation generated successfully',
},
[StatusIconState.Error]: {
classes: 'status-icon fas fa-times-circle',
color: '#FF6645',
ariaLabel: 'Error generating explanation',
},
[StatusIconState.Hidden]: {
classes: 'status-icon fas d-none',
color: '',
ariaLabel: '',
},
} as const;
export class ExplainView extends Pane<ExplainViewState> {
private lastResult: CompilationResult | null = null;
private compiler: CompilerInfo | null = null;
private readonly statusIcon: JQuery;
private readonly consentElement: JQuery;
private readonly noAiElement: JQuery;
private readonly contentElement: JQuery;
private readonly bottomBarElement: JQuery;
private readonly statsElement: JQuery;
private readonly audienceSelect: JQuery;
private readonly explanationSelect: JQuery;
private readonly audienceInfoButton: JQuery;
private readonly explanationInfoButton: JQuery;
private readonly explainApiEndpoint: string;
private readonly fontScale: FontScale;
// Use a static variable to persist consent across all instances during the session
private static consentGiven = false;
// Static cache for available options (shared across all instances)
private static availableOptions: AvailableOptions | null = null;
private static optionsFetchPromise: Promise<AvailableOptions> | null = null;
// Static explanation cache shared across all instances (200KB limit)
private static cache: LRUCache<string, ClaudeExplainResponse> | null = null;
// Instance variables for selected options
private selectedAudience: string;
private selectedExplanation: string;
private isInitializing = true;
// Store compilation results that arrive before initialization completes
private pendingCompilationResult: CompilationResult | null = null;
constructor(hub: Hub, container: Container, state: ExplainViewState) {
super(hub, container, state);
this.explainApiEndpoint = options.explainApiEndpoint ?? '';
// Initialize static cache if not already done
if (!ExplainView.cache) {
ExplainView.cache = new LRUCache({
maxSize: 200 * 1024,
sizeCalculation: n => JSON.stringify(n).length,
});
}
this.statusIcon = this.domRoot.find('.status-icon');
this.consentElement = this.domRoot.find('.explain-consent');
this.noAiElement = this.domRoot.find('.explain-no-ai');
this.contentElement = this.domRoot.find('.explain-content');
this.bottomBarElement = this.domRoot.find('.explain-bottom-bar');
this.statsElement = this.domRoot.find('.explain-stats');
this.audienceSelect = this.domRoot.find('.explain-audience');
this.explanationSelect = this.domRoot.find('.explain-type');
this.audienceInfoButton = this.domRoot.find('.explain-audience-info');
this.explanationInfoButton = this.domRoot.find('.explain-type-info');
this.fontScale = new FontScale(this.domRoot, state, '.explain-content');
this.fontScale.on('change', this.updateState.bind(this));
this.attachEventListeners();
void this.initializeOptions();
this.contentElement.text('Waiting for compilation...');
this.isAwaitingInitialResults = true;
this.eventHub.emit('explainViewOpened', this.compilerInfo.compilerId);
}
private handleConsentClick(): void {
ExplainView.consentGiven = true;
this.consentElement.addClass('d-none');
void this.fetchExplanation();
}
private handleReloadClick(): void {
void this.fetchExplanation(true);
}
private handleAudienceChange(): void {
this.selectedAudience = this.audienceSelect.val() as string;
this.updateState();
this.refreshExplanationIfReady();
}
private handleExplanationChange(): void {
this.selectedExplanation = this.explanationSelect.val() as string;
this.updateState();
this.refreshExplanationIfReady();
}
private refreshExplanationIfReady(): void {
if (ExplainView.consentGiven && this.lastResult) {
void this.fetchExplanation();
}
}
private attachEventListeners(): void {
this.consentElement.find('.consent-btn').on('click', this.handleConsentClick.bind(this));
this.bottomBarElement.find('.explain-reload').on('click', this.handleReloadClick.bind(this));
this.audienceSelect.on('change', this.handleAudienceChange.bind(this));
this.explanationSelect.on('change', this.handleExplanationChange.bind(this));
}
override getInitialHTML(): string {
return $('#explain').html();
}
private async initializeOptions(): Promise<void> {
try {
this.populateSelectOptions(await this.fetchAvailableOptions());
this.isInitializing = false;
// Process any compilation results that arrived while we were initializing.
if (this.pendingCompilationResult) {
this.handleCompilationResult(this.pendingCompilationResult);
this.pendingCompilationResult = null;
}
} catch (error) {
this.isInitializing = false;
console.error('Failed to initialize options:', error);
this.showExplainUnavailable();
// Even if initialization failed, clear any pending results
this.pendingCompilationResult = null;
}
}
private showExplainUnavailable(): void {
const emptyOptions = [{value: '', description: 'Service unavailable'}];
this.populateSelect(this.audienceSelect, emptyOptions);
this.populateSelect(this.explanationSelect, emptyOptions);
this.audienceSelect.prop('disabled', true);
this.explanationSelect.prop('disabled', true);
this.contentElement.html(
'<div class="alert alert-warning">Claude Explain is currently unavailable due to a service error. Please try again later.</div>',
);
}
private populateSelect(selectElement: JQuery, optionsList: Array<{value: string; description: string}>): void {
selectElement.empty();
optionsList.forEach(option => {
const optionElement = $('<option></option>')
.attr('value', option.value)
.text(capitaliseFirst(option.value))
.attr('title', option.description);
selectElement.append(optionElement);
});
}
private populateSelectOptions(options: AvailableOptions): void {
this.populateSelect(this.audienceSelect, options.audience);
this.populateSelect(this.explanationSelect, options.explanation);
this.updatePopoverContent(options);
if (this.isInitializing) {
// During initialisation: trust saved state completely, no validation
this.audienceSelect.val(this.selectedAudience);
this.explanationSelect.val(this.selectedExplanation);
} else {
// After initialisation: validate user changes normally
const validAudienceValue = options.audience.some(opt => opt.value === this.selectedAudience)
? this.selectedAudience
: defaultAudienceType;
const validExplanationValue = options.explanation.some(opt => opt.value === this.selectedExplanation)
? this.selectedExplanation
: defaultExplanationType;
this.selectedAudience = validAudienceValue;
this.selectedExplanation = validExplanationValue;
this.audienceSelect.val(validAudienceValue);
this.explanationSelect.val(validExplanationValue);
}
}
private updatePopoverContent(options: AvailableOptions): void {
const audienceContent = createPopoverContent(options.audience);
const explanationContent = createPopoverContent(options.explanation);
initPopover(this.audienceInfoButton, {
content: audienceContent,
html: true,
placement: 'bottom',
trigger: 'focus',
});
initPopover(this.explanationInfoButton, {
content: explanationContent,
html: true,
placement: 'bottom',
trigger: 'focus',
});
}
private async fetchAvailableOptions(): Promise<AvailableOptions> {
// If we already have options cached, return them
if (ExplainView.availableOptions) return ExplainView.availableOptions;
// If we're already fetching, wait for that promise
if (ExplainView.optionsFetchPromise) return ExplainView.optionsFetchPromise;
// Else, go fetch the options
ExplainView.optionsFetchPromise = (async () => {
try {
const response = await fetch(this.explainApiEndpoint, {
method: 'GET',
headers: {'Content-Type': 'application/json'},
});
if (!response.ok) {
throw new Error(`Failed to fetch options: ${response.status} ${response.statusText}`);
}
const options = (await response.json()) as AvailableOptions;
ExplainView.availableOptions = options;
return options;
} finally {
ExplainView.optionsFetchPromise = null;
}
})();
return ExplainView.optionsFetchPromise;
}
override initializeStateDependentProperties(state: ExplainViewState): void {
this.selectedAudience = state.audience ?? defaultAudienceType;
this.selectedExplanation = state.explanation ?? defaultExplanationType;
}
override getCurrentState(): ExplainViewState {
const state = super.getCurrentState() as ExplainViewState;
state.audience = this.selectedAudience;
state.explanation = this.selectedExplanation;
return state;
}
override onCompiler(
compilerId: number,
compiler: CompilerInfo | null,
_compilerOptions: string,
editorId: number,
treeId: number,
): void {
if (this.compilerInfo.compilerId !== compilerId) return;
this.compilerInfo.compilerName = compiler ? compiler.name : '';
this.compilerInfo.editorId = editorId;
this.compilerInfo.treeId = treeId;
this.compiler = compiler;
this.updateTitle();
}
private hideSpecialUIElements(): void {
this.consentElement.addClass('d-none');
this.noAiElement.addClass('d-none');
}
private handleCompilationResult(result: CompilationResult): void {
if (this.isInitializing) {
// Store for processing after initialization completes
this.pendingCompilationResult = result;
return;
}
if (result.code !== 0) {
this.contentElement.text('Cannot explain: Compilation failed');
return;
}
if (result.source && checkForNoAiDirective(result.source)) {
this.showNoAiDirective();
return;
}
if (ExplainView.consentGiven) {
void this.fetchExplanation();
} else {
this.showConsentUI();
}
}
private showNoAiDirective(): void {
this.noAiElement.removeClass('d-none');
this.contentElement.text('');
}
private showConsentUI(): void {
this.consentElement.removeClass('d-none');
this.contentElement.text('Claude needs your consent to explain this code.');
}
override onCompileResult(id: number, compiler: CompilerInfo, result: CompilationResult): void {
if (id !== this.compilerInfo.compilerId) return;
this.compiler = compiler;
this.lastResult = result;
this.isAwaitingInitialResults = false;
this.hideSpecialUIElements();
this.handleCompilationResult(result);
}
private setStatusIcon(state: StatusIconState): void {
const config = statusIconConfigs[state];
this.statusIcon
.removeClass()
.addClass(config.classes)
.css('color', config.color)
.attr('aria-label', config.ariaLabel);
}
private showLoading(): void {
this.setStatusIcon(StatusIconState.Loading);
this.contentElement.text('Generating explanation...');
}
private hideLoading(): void {
this.setStatusIcon(StatusIconState.Hidden);
}
private showSuccess(): void {
this.setStatusIcon(StatusIconState.Success);
}
private showError(): void {
this.setStatusIcon(StatusIconState.Error);
}
private showBottomBar(): void {
this.bottomBarElement.removeClass('d-none');
}
private updateStatsInBottomBar(
data: ClaudeExplainResponse,
clientCacheHit: boolean,
serverCacheHit: boolean,
): void {
const stats = formatStatsText(data, clientCacheHit, serverCacheHit);
if (stats.length > 0) {
this.statsElement.text(stats.join(' | '));
}
}
private displayCachedResult(cachedResult: ClaudeExplainResponse): void {
this.hideLoading();
this.showSuccess();
this.renderMarkdown(cachedResult.explanation);
this.showBottomBar();
if (cachedResult.usage) {
this.updateStatsInBottomBar(cachedResult, true, false);
}
}
private async fetchFromAPI(payload: ExplainRequest): Promise<ClaudeExplainResponse> {
const response = await window.fetch(this.explainApiEndpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Server returned ${response.status} ${response.statusText}`);
}
return response.json() as Promise<ClaudeExplainResponse>;
}
private getExplainContext(): ExplainContext {
return {
lastResult: this.lastResult,
compiler: this.compiler,
selectedAudience: this.selectedAudience,
selectedExplanation: this.selectedExplanation,
explainApiEndpoint: this.explainApiEndpoint,
consentGiven: ExplainView.consentGiven,
availableOptions: ExplainView.availableOptions,
};
}
private processExplanationResponse(data: ClaudeExplainResponse, cacheKey: string): void {
this.hideLoading();
if (data.status === 'error') {
this.showError();
this.contentElement.text(`Error: ${data.message || 'Unknown error'}`);
return;
}
this.showSuccess();
ExplainView.cache!.set(cacheKey, data);
this.renderMarkdown(data.explanation);
this.showBottomBar();
if (data.usage) {
this.updateStatsInBottomBar(data, false, data.cached);
}
}
private async fetchExplanation(bypassCache = false): Promise<void> {
const context = this.getExplainContext();
const validationResult = validateExplainPreconditions(context);
if (!validationResult.success) {
switch (validationResult.errorCode) {
case ValidationErrorCode.NO_AI_DIRECTIVE_FOUND:
this.hideLoading();
this.noAiElement.removeClass('d-none');
this.contentElement.text('');
break;
case ValidationErrorCode.MISSING_REQUIRED_DATA:
// Silent return - this is expected during normal UI flow (before compilation, before consent, etc.)
break;
default:
// Show all other validation errors to help with debugging
this.contentElement.text(`Error: ${validationResult.message}`);
break;
}
return;
}
this.contentElement.empty();
this.showLoading();
try {
const payload = buildExplainRequest(context, bypassCache);
const cacheKey = generateCacheKey(payload);
// Check cache first unless bypassing
if (!bypassCache) {
const cachedResult = ExplainView.cache!.get(cacheKey);
if (cachedResult) {
this.displayCachedResult(cachedResult);
return;
}
}
const data = await this.fetchFromAPI(payload);
this.processExplanationResponse(data, cacheKey);
} catch (error) {
this.handleFetchError(error);
}
}
private handleFetchError(error: unknown): void {
this.hideLoading();
this.showError();
this.contentElement.text(formatErrorMessage(error));
SentryCapture(error);
}
private renderMarkdown(markdown: string): void {
this.contentElement.html(formatMarkdown(markdown));
}
override resize(): void {
utils.updateAndCalcTopBarHeight(this.domRoot, this.topBar, this.hideable);
}
override getDefaultPaneName(): string {
return 'Claude Explain';
}
override close(): void {
this.eventHub.emit('explainViewClosed', this.compilerInfo.compilerId);
this.eventHub.unsubscribe();
}
}