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.
This commit is contained in:
Matt Godbolt
2025-08-05 16:42:48 -05:00
committed by GitHub
parent e9011ec592
commit 50ec53d0e7
6 changed files with 916 additions and 9 deletions

165
cypress/README.md Normal file
View File

@@ -0,0 +1,165 @@
# Cypress E2E Tests
This directory contains end-to-end tests for Compiler Explorer using Cypress.
## Running Tests
### Starting Compiler Explorer for Testing
First, start a local Compiler Explorer instance with a clean configuration:
```bash
npm run dev -- --language c++ --no-local
```
The `--no-local` flag is important as it ensures your setup is clean of any local properties.
### Running Cypress Tests
In another terminal:
```bash
# Run all Cypress tests
npm run cypress
# Run specific test file
npm run cypress -- run --spec "cypress/e2e/claude-explain.cy.ts"
# Open Cypress interactive UI (recommended for development)
npm run cypress:open
```
When using the interactive UI, choose "E2E Testing" and select your browser.
## Important Testing Patterns & Lessons Learned
### 1. **Always Use `:visible` Selectors**
GoldenLayout creates template elements that exist in the DOM but aren't visible. Always use `:visible` to avoid selecting template elements:
```javascript
// ❌ Bad - might select template elements
cy.get('.explain-content').should('contain', 'text');
// ✅ Good - only selects visible elements
cy.get('.explain-content:visible').should('contain', 'text');
```
### 2. **Performance: Clear Intercepts in `afterEach`**
Cypress intercepts accumulate across tests causing O(n²) performance degradation. Always clear them:
```javascript
import {clearAllIntercepts} from '../support/utils';
afterEach(() => {
// Use the utility function to clear intercepts
clearAllIntercepts();
// Or manually clear them:
cy.state('routes', []);
cy.state('aliases', {});
// ... other cleanup
});
```
### 3. **Mock Setup Timing is Critical**
Always set up API mocks BEFORE any action that might trigger requests:
```javascript
// ❌ Bad - pane constructor might make requests before mocks are ready
openClaudeExplainPane();
mockClaudeExplainAPI();
// ✅ Good - mocks ready before pane opens
mockClaudeExplainAPI();
openClaudeExplainPane();
```
### 4. **Wait for Async DOM Updates**
Don't just wait for API calls - wait for the actual DOM changes:
```javascript
// ❌ Bad - API completes but DOM might not be updated yet
cy.wait('@getOptions');
cy.get('.dropdown').select('value');
// ✅ Good - wait for specific DOM state
cy.wait('@getOptions');
cy.get('.dropdown option[value="loading"]').should('not.exist');
cy.get('.dropdown').select('value');
```
### 5. **Use Test Data, Not Production Values**
Always use clearly fake test data to:
- Prevent confusion with real values
- Make it obvious when viewing test output
- Ensure tests never accidentally hit production APIs
```javascript
// Use values like: test_first, test_second, focus_a, focus_b
// Not: beginner, expert, assembly, optimization
```
### 6. **Helper Functions for Common Patterns**
Extract common test patterns to helpers, but keep them in the test file if they're specific to one feature:
```javascript
// In test file for feature-specific helpers
function openClaudeExplainPaneWithOptions() {
mockClaudeExplainAPI();
openClaudeExplainPane();
cy.wait('@getOptions');
waitForDropdownsToLoad();
}
// In utils.ts for general helpers
export function setMonacoEditorContent(content: string) { ... }
```
### 7. **State Persistence Between Pane Instances**
Be aware that some state might be static (shared between instances) while other state is per-instance:
- Static state (like consent, cache) persists when closing/reopening panes
- Instance state is lost when panes close
- This affects how you structure tests for features that should persist
### 8. **Block Production APIs**
Always block production API calls in tests to catch configuration issues:
```javascript
cy.intercept('https://api.compiler-explorer.com/**', {
statusCode: 500,
body: {error: 'BLOCKED PRODUCTION API'}
}).as('blockedProduction');
```
## Common Issues & Solutions
### Tests Getting Progressively Slower
- **Cause**: Intercept accumulation
- **Solution**: Clear intercepts in `afterEach` using `clearAllIntercepts()` from utils or manually with `cy.state('routes', [])`
### "Element not found" Despite Being Visible
- **Cause**: Selecting template elements from GoldenLayout
- **Solution**: Use `:visible` pseudo-selector
### API Mocks Not Working
- **Cause**: Mock setup after the request is made
- **Solution**: Set up mocks before opening panes or triggering actions
### Dropdown Selection Failing
- **Cause**: Trying to select before async population completes
- **Solution**: Wait for loading indicators to disappear first
### State Not Persisting in Tests
- **Cause**: Not understanding static vs instance variables
- **Solution**: Check if the feature uses static state that should persist
## Test Organization
- Keep feature-specific test helpers in the test file itself
- Only put truly reusable utilities in `support/utils.ts`
- Use descriptive helper function names that indicate what they do
- Group related tests in `describe` blocks
- Use consistent test data across all tests in a feature
## Debugging Tips
1. Use `cy.log()` to debug what values you're actually getting
2. Check the Cypress command log for unexpected API calls
3. Look for console errors that might indicate JavaScript issues
4. Use `.then()` to inspect element state at specific points
5. Check network tab for requests hitting production instead of mocks

View File

@@ -0,0 +1,613 @@
// 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 {clearAllIntercepts, setMonacoEditorContent, stubConsoleOutput} from '../support/utils';
// Claude Explain specific test utilities
function mockClaudeExplainAPI() {
// Mock GET request for options
cy.intercept('GET', 'http://test.localhost/fake-api/explain', {
statusCode: 200,
body: {
audience: [
{value: 'test_first', description: 'Test first audience level'},
{value: 'test_second', description: 'Test second audience level'},
{value: 'test_third', description: 'Test third audience level'},
],
explanation: [
{value: 'focus_a', description: 'Test focus A explanation type'},
{value: 'focus_b', description: 'Test focus B explanation type'},
{value: 'focus_c', description: 'Test focus C explanation type'},
],
},
}).as('getOptions');
// Mock POST request for explanation
cy.intercept('POST', 'http://test.localhost/fake-api/explain', {
statusCode: 200,
body: {
status: 'success',
explanation: '## Test Assembly Explanation\nThis is a test explanation from the mocked API.',
usage: {totalTokens: 150},
model: 'claude-3-test',
cost: {totalCost: 0.001},
},
}).as('explainRequest');
}
function openClaudeExplainPane() {
cy.get('[data-cy="new-compiler-dropdown-btn"]:visible').click();
cy.get('[data-cy="new-view-explain-btn"]:visible').click();
}
function setupClaudeExplainEnvironment() {
// Start with clean intercepts
clearAllIntercepts();
// Belt and suspenders: block production API calls
cy.intercept('https://api.compiler-explorer.com/**', {statusCode: 500, body: {error: 'BLOCKED PRODUCTION API'}}).as(
'blockedProduction',
);
// Set up configuration
cy.visit('/', {
onBeforeLoad: (win: any) => {
stubConsoleOutput(win);
win.compilerExplorerOptions = win.compilerExplorerOptions || {};
win.compilerExplorerOptions.explainApiEndpoint = 'http://test.localhost/fake-api/explain';
},
});
// Force override after page loads
cy.window().then((win: any) => {
win.compilerExplorerOptions.explainApiEndpoint = 'http://test.localhost/fake-api/explain';
});
}
function mockClaudeExplainAPIWithOptions() {
// Just call the standard mock for consistency
mockClaudeExplainAPI();
}
function mockSuccessfulExplanation() {
cy.intercept('POST', 'http://test.localhost/fake-api/explain', {
statusCode: 200,
body: {
status: 'success',
explanation:
'## Understanding Your Assembly Code\n\nThis simple program:\n\n```cpp\nint main() {\n return 42;\n}\n```\n\nCompiles to very efficient assembly that:\n1. Sets up the stack frame\n2. Moves the value 42 into the return register\n3. Cleans up and returns\n\n### Key Instructions\n- `mov eax, 42` - Places our return value in the EAX register\n- `ret` - Returns control to the caller',
usage: {
inputTokens: 250,
outputTokens: 120,
totalTokens: 370,
},
model: 'claude-3-haiku',
cost: {
inputCost: 0.00025,
outputCost: 0.0012,
totalCost: 0.00145,
},
},
}).as('explainRequest');
}
function mockAPIError() {
cy.intercept('POST', 'http://test.localhost/fake-api/explain', {
statusCode: 500,
body: {error: 'Internal server error'},
}).as('explainError');
}
function giveConsentAndWait() {
cy.get('.consent-btn:visible').click();
cy.wait('@explainRequest');
}
function mockCachedExplanation() {
const mockResponse = {
status: 'success',
explanation: 'This is a cached explanation',
usage: {totalTokens: 100},
model: 'claude-3',
cost: {totalCost: 0.001},
};
cy.intercept('POST', 'http://test.localhost/fake-api/explain', mockResponse).as('explainRequest');
}
// Simplified test helpers
function waitForDropdownsToLoad() {
cy.get('.explain-audience:visible option[value="loading"]').should('not.exist');
cy.get('.explain-type:visible option[value="loading"]').should('not.exist');
}
function openClaudeExplainPaneWithOptions() {
mockClaudeExplainAPI();
openClaudeExplainPane();
cy.wait('@getOptions');
waitForDropdownsToLoad();
}
describe('Claude Explain feature', () => {
beforeEach(() => {
setupClaudeExplainEnvironment();
});
afterEach(() => {
// Clear all Cypress intercepts to prevent O(n²) accumulation
cy.state('routes', []);
cy.state('aliases', {});
// Clear storage and state but avoid aggressive DOM manipulation
cy.window().then(win => {
// Clear any stored explain state/cache
win.localStorage.clear();
win.sessionStorage.clear();
// Reset compiler explorer options
win.compilerExplorerOptions = {};
// Instead of force-closing tabs, let natural cleanup happen
// The next beforeEach will reload the page anyway
});
});
describe('Basic functionality', () => {
it('should open Claude Explain pane from compiler toolbar', () => {
openClaudeExplainPaneWithOptions();
// Verify the explain pane opened
cy.get('.lm_title').should('contain', 'Claude Explain');
cy.get('.explain-consent').should('be.visible');
});
it('should show consent dialog on first use', () => {
openClaudeExplainPaneWithOptions();
// Verify consent dialog is shown (use :visible to avoid template elements)
cy.get('.explain-consent:visible').should('be.visible');
cy.get('.explain-consent:visible').should('contain', 'Consent Request');
cy.get('.explain-consent:visible').should('contain', 'Claude Explain will send your source code');
cy.get('.explain-consent:visible').should('contain', 'compilation output');
cy.get('.explain-consent:visible').should('contain', 'Anthropic');
// Verify consent button exists
cy.get('.consent-btn:visible').should('be.visible');
cy.get('.consent-btn:visible').should('contain', 'Yes, explain this code');
});
it('should remember consent for the session', () => {
mockClaudeExplainAPIWithOptions();
mockClaudeExplainAPI();
// Open first explain pane and give consent
openClaudeExplainPane();
cy.get('.consent-btn:visible').click();
cy.wait('@explainRequest');
// Close the explain pane
cy.get('.lm_close_tab').last().click();
// Set up mocks again before reopening
mockClaudeExplainAPIWithOptions();
// Open a new explain pane
openClaudeExplainPane();
// Verify consent dialog is NOT shown again (check for visible consent, not template)
cy.get('.explain-consent:visible').should('not.exist');
cy.get('.explain-content').should('be.visible');
});
});
describe('no-ai directive handling', () => {
it('should detect no-ai directive and show special message', () => {
// Update code to include no-ai directive
setMonacoEditorContent('// no-ai\nint main() {\n return 42;\n}');
openClaudeExplainPaneWithOptions();
// Verify no-ai message is shown (use :visible to avoid template elements)
cy.get('.explain-no-ai:visible').should('be.visible');
cy.get('.explain-no-ai:visible').should('contain', 'AI Explanation Not Available');
cy.get('.explain-no-ai:visible').should('contain', 'no-ai');
// Verify consent dialog is NOT shown (check visible elements only)
cy.get('.explain-consent:visible').should('not.exist');
});
it('should detect case-insensitive no-ai directive', () => {
// Update code with different case
setMonacoEditorContent('// NO-AI\nint main() {\n return 0;\n}');
openClaudeExplainPaneWithOptions();
// Should still detect it (use :visible to avoid template elements)
cy.get('.explain-no-ai:visible').should('be.visible');
});
});
describe('API interaction and explanations', () => {
it('should fetch and display explanation after consent', () => {
mockClaudeExplainAPIWithOptions();
mockSuccessfulExplanation();
// Open explain pane
openClaudeExplainPane();
// Give consent and wait for API call
giveConsentAndWait();
cy.wait('@getOptions');
// Verify explanation is displayed (use :visible to avoid template elements)
cy.get('.explain-content:visible').should('be.visible');
cy.get('.explain-content:visible').should('contain', 'Understanding Your Assembly Code');
cy.get('.explain-content:visible').should('contain', 'mov eax, 42');
// Verify markdown is rendered (should have headers)
cy.get('.explain-content:visible h2').should('exist');
cy.get('.explain-content:visible h3').should('exist');
cy.get('.explain-content:visible code').should('exist');
// Verify stats are shown
cy.get('.explain-stats:visible').should('be.visible');
cy.get('.explain-stats:visible').should('contain', 'Fresh');
cy.get('.explain-stats:visible').should('contain', 'Model: claude-3-haiku');
cy.get('.explain-stats:visible').should('contain', 'Tokens: 370');
cy.get('.explain-stats:visible').should('contain', 'Cost: $0.001450');
});
it('should handle API errors gracefully', () => {
mockClaudeExplainAPIWithOptions();
mockAPIError();
// Open pane and consent
openClaudeExplainPane();
cy.get('.consent-btn:visible').click();
// Wait for error
cy.wait('@explainError');
// Verify error is displayed (use :visible to avoid template elements)
cy.get('.explain-content:visible').should('contain', 'Error');
cy.get('.explain-content:visible').should('contain', 'Server returned 500');
// Verify error icon
cy.get('.status-icon.fa-times-circle:visible').should('be.visible');
});
it('should show loading state during API call', () => {
mockClaudeExplainAPIWithOptions();
// Mock slow API response with direct reply (no network request)
cy.intercept('POST', 'http://test.localhost/fake-api/explain', {
delay: 1000,
statusCode: 200,
body: {
status: 'success',
explanation: 'Test explanation',
},
}).as('slowExplain');
// Open pane and consent
openClaudeExplainPane();
cy.get('.consent-btn:visible').click();
// Verify loading state (use :visible to avoid template elements)
cy.get('.status-icon.fa-spinner.fa-spin:visible').should('be.visible');
cy.get('.explain-content:visible').should('contain', 'Generating explanation...');
// Wait for response
cy.wait('@slowExplain');
// Verify loading state is gone
cy.get('.status-icon.fa-spinner:visible').should('not.exist');
cy.get('.status-icon.fa-check-circle:visible').should('be.visible');
});
});
describe('Options and customization', () => {
it('should load and display audience and explanation options', () => {
openClaudeExplainPaneWithOptions();
// Verify audience dropdown (use :visible to avoid template elements)
cy.get('.explain-audience:visible').should('be.visible');
cy.get('.explain-audience:visible option').should('have.length', 3);
cy.get('.explain-audience:visible option[value="test_first"]').should('exist');
cy.get('.explain-audience:visible option[value="test_second"]').should('exist');
cy.get('.explain-audience:visible option[value="test_third"]').should('exist');
// Verify explanation type dropdown
cy.get('.explain-type:visible').should('be.visible');
cy.get('.explain-type:visible option').should('have.length', 3);
cy.get('.explain-type:visible option[value="focus_a"]').should('exist');
cy.get('.explain-type:visible option[value="focus_b"]').should('exist');
cy.get('.explain-type:visible option[value="focus_c"]').should('exist');
// Verify info buttons
cy.get('.explain-audience-info:visible').should('be.visible');
cy.get('.explain-type-info:visible').should('be.visible');
});
it('should show popover descriptions for options', () => {
openClaudeExplainPaneWithOptions();
// Click audience info button (use :visible to avoid template elements)
cy.get('.explain-audience-info:visible').click();
// Verify popover appears
cy.get('.popover:visible').should('be.visible');
cy.get('.popover-body:visible').should('contain', 'Test_first:');
cy.get('.popover-body:visible').should('contain', 'Test first audience level');
// Click away to close
cy.get('.explain-content:visible').click();
cy.get('.popover:visible').should('not.exist');
// Click explanation info button
cy.get('.explain-type-info:visible').click();
cy.get('.popover:visible').should('be.visible');
cy.get('.popover-body:visible').should('contain', 'Focus_a:');
cy.get('.popover-body:visible').should('contain', 'Test focus A');
});
it('should re-fetch explanation when options change', () => {
// Block production API
cy.intercept('https://api.compiler-explorer.com/**', {
statusCode: 500,
body: {error: 'BLOCKED PRODUCTION API'},
}).as('blockedProduction');
// Only set up GET mock for options, not POST (we have custom POST below)
cy.intercept('GET', 'http://test.localhost/fake-api/explain', {
statusCode: 200,
body: {
audience: [
{value: 'test_first', description: 'Test first audience level'},
{value: 'test_second', description: 'Test second audience level'},
{value: 'test_third', description: 'Test third audience level'},
],
explanation: [
{value: 'focus_a', description: 'Test focus A explanation type'},
{value: 'focus_b', description: 'Test focus B explanation type'},
{value: 'focus_c', description: 'Test focus C explanation type'},
],
},
}).as('getOptions');
let explainCallCount = 0;
cy.intercept('POST', 'http://test.localhost/fake-api/explain', (req: any) => {
explainCallCount++;
req.reply({
status: 'success',
explanation: `Explanation #${explainCallCount} for ${req.body.audience} audience`,
usage: {totalTokens: 100},
model: 'test-model',
cost: {totalCost: 0.001},
});
}).as('explainRequest');
// Open pane and wait for options to load first
openClaudeExplainPane();
cy.wait('@getOptions');
waitForDropdownsToLoad();
// Give consent
giveConsentAndWait();
// Wait a bit for content to render
cy.wait(100);
// Verify initial explanation (just check for the count, not the full text)
cy.get('.explain-content:visible').should('contain', 'Explanation #1');
// Change audience level
cy.get('.explain-audience:visible').select('test_second');
// Should trigger new request
cy.wait('@explainRequest');
cy.get('.explain-content:visible').should('contain', 'Explanation #2');
// Change explanation type
cy.get('.explain-type:visible').select('focus_b');
// Should trigger another request
cy.wait('@explainRequest');
cy.get('.explain-content:visible').should('contain', 'Explanation #3');
});
});
describe('Caching and reload', () => {
it('should cache responses and show cache status', () => {
mockClaudeExplainAPIWithOptions();
mockCachedExplanation();
// Open pane and get first explanation
openClaudeExplainPane();
giveConsentAndWait();
// Verify fresh status (use :visible to avoid template elements)
cy.get('.explain-stats:visible').should('contain', 'Fresh');
// Close and reopen pane (should use client cache)
cy.get('.lm_close_tab').last().click();
// Set up options mock only (needed for pane constructor) but NOT explanation mock
mockClaudeExplainAPIWithOptions();
openClaudeExplainPane();
// Since consent was already given, it should go straight to cached content
// (no consent dialog should appear)
cy.get('.explain-consent:visible').should('not.exist');
// Should use cached explanation data
cy.get('.explain-content:visible').should('contain', 'This is a cached explanation');
cy.get('.explain-stats:visible').should('contain', 'Cached (client)');
});
it('should bypass cache when reload button is clicked', () => {
mockClaudeExplainAPIWithOptions();
let callCount = 0;
cy.intercept('POST', 'http://test.localhost/fake-api/explain', (req: any) => {
callCount++;
const isBypassCache = req.body.bypassCache === true;
req.reply({
status: 'success',
explanation: `Explanation #${callCount}${isBypassCache ? ' (bypassed cache)' : ''}`,
});
}).as('explainRequest');
// Get initial explanation
openClaudeExplainPane();
giveConsentAndWait();
cy.get('.explain-content:visible').should('contain', 'Explanation #1');
// Click reload button (use :visible to avoid template elements)
cy.get('.explain-reload:visible').click();
cy.wait('@explainRequest');
// Should have made a new request with bypassCache flag
cy.get('.explain-content:visible').should('contain', 'Explanation #2 (bypassed cache)');
});
});
describe('Compilation state handling', () => {
it('should handle compilation failures', () => {
// Add invalid code
setMonacoEditorContent('this is not valid C++ code');
openClaudeExplainPaneWithOptions();
// Should show compilation failed message (use :visible to avoid template elements)
cy.get('.explain-content:visible').should('contain', 'Cannot explain: Compilation failed');
// Should not show consent dialog
cy.get('.explain-consent:visible').should('not.exist');
});
it('should update explanation when code changes', () => {
mockClaudeExplainAPIWithOptions();
let explainCount = 0;
cy.intercept('POST', 'http://test.localhost/fake-api/explain', (req: any) => {
explainCount++;
req.reply({
status: 'success',
explanation: `Explanation for version ${explainCount}`,
});
}).as('explainRequest');
// Open pane and get initial explanation
openClaudeExplainPane();
giveConsentAndWait();
cy.get('.explain-content:visible').should('contain', 'Explanation for version 1');
// Change the code
setMonacoEditorContent('int main() {\n return 100;\n}');
// Wait for new explanation
cy.wait('@explainRequest');
cy.get('.explain-content:visible').should('contain', 'Explanation for version 2');
});
});
describe('UI state and theming', () => {
it('should persist option selections in state', () => {
// Mock API with test options
mockClaudeExplainAPI();
// Open pane
openClaudeExplainPane();
// Add error listener to catch JS errors
cy.window().then(win => {
win.addEventListener('error', e => {
cy.log('JS Error:', e.message, 'at', e.filename + ':' + e.lineno);
});
win.addEventListener('unhandledrejection', e => {
cy.log('Unhandled Promise Rejection:', e.reason);
});
});
// Wait for options to load
cy.wait('@getOptions');
// Wait for dropdown to be populated (wait for "Loading..." to disappear)
cy.get('.explain-audience:visible option[value="loading"]').should('not.exist');
cy.get('.explain-type:visible option[value="loading"]').should('not.exist');
// Give consent first to make dropdowns available
cy.get('.consent-btn:visible').click();
// Wait for dropdowns to be populated with actual options
cy.get('.explain-audience:visible option[value="test_second"]').should('exist');
cy.get('.explain-type:visible option[value="focus_b"]').should('exist');
// Now change options after consent (use :visible to avoid template elements)
cy.get('.explain-audience:visible').select('test_second');
cy.get('.explain-type:visible').select('focus_b');
// Wait for the explanation request triggered by option changes
cy.wait('@explainRequest');
// Get the current URL (which includes state)
cy.url().then((url: any) => {
// Clear intercepts from previous test
cy.state('routes', []);
cy.state('aliases', {});
// Set up mocks BEFORE visiting
mockClaudeExplainAPI();
// Block production API
cy.intercept('https://api.compiler-explorer.com/**', {
statusCode: 500,
body: {error: 'BLOCKED PRODUCTION API'},
}).as('blockedProduction');
// Visit the URL with configuration
cy.visit(url, {
onBeforeLoad: (win: any) => {
win.compilerExplorerOptions = win.compilerExplorerOptions || {};
win.compilerExplorerOptions.explainApiEndpoint = 'http://test.localhost/fake-api/explain';
},
});
// Force override after page loads (in case it gets reset)
cy.window().then((win: any) => {
win.compilerExplorerOptions = win.compilerExplorerOptions || {};
win.compilerExplorerOptions.explainApiEndpoint = 'http://test.localhost/fake-api/explain';
});
// Verify options are restored
cy.get('.explain-audience:visible').should('have.value', 'test_second');
cy.get('.explain-type:visible').should('have.value', 'focus_b');
});
});
});
});

View File

@@ -0,0 +1,68 @@
// 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 {setMonacoEditorContent} from '../support/utils';
describe('Monaco Editor Utilities', () => {
beforeEach(() => {
cy.visit('/');
});
it('should set simple code content', () => {
const simpleCode = 'int main() { return 42; }';
setMonacoEditorContent(simpleCode);
// Verify the content was set (basic check)
cy.get('.monaco-editor').should('contain.text', 'main');
});
it('should set complex multi-line code content', () => {
const complexCode = `#include <iostream>
#include <vector>
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5};
for (int num : nums) {
std::cout << num << std::endl;
}
return 0;
}`;
setMonacoEditorContent(complexCode);
// Verify the content was set (basic check)
cy.get('.monaco-editor').should('contain.text', 'iostream');
cy.get('.monaco-editor').should('contain.text', 'vector');
});
it('should handle special characters and quotes', () => {
const codeWithSpecialChars = `const char* message = "Hello, \"World\"!";
int result = (x > 0) ? x : -x;`;
setMonacoEditorContent(codeWithSpecialChars);
// Verify the content was set (basic check)
cy.get('.monaco-editor').should('contain.text', 'Hello');
});
});

View File

@@ -11,3 +11,55 @@ export function assertNoConsoleOutput() {
cy.get('@consoleWarn').should('not.be.called');
cy.get('@consoleError').should('not.be.called');
}
/**
* Clear all network intercepts to prevent accumulation
*/
export function clearAllIntercepts() {
// Clear any existing intercepts by visiting a clean page and resetting
cy.window().then((win: any) => {
// Reset any cached state
win.compilerExplorerOptions = {};
});
}
/**
* Sets content in Monaco editor using a synthetic paste event
* @param content - The code content to set
* @param editorIndex - Which editor to target (default: 0 for first editor)
*/
export function setMonacoEditorContent(content: string, editorIndex = 0) {
// Wait for Monaco editor to be visible in DOM
cy.get('.monaco-editor').should('be.visible');
// Select all and delete existing content
cy.get('.monaco-editor textarea').eq(editorIndex).focus().type('{ctrl}a{del}', {force: true});
// Trigger a paste event with our content
cy.get('.monaco-editor textarea')
.eq(editorIndex)
.then(($element: any) => {
const el = $element[0];
// Create and dispatch a paste event with our data
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: new DataTransfer(),
});
// Add our text to the clipboard data
pasteEvent.clipboardData?.setData('text/plain', content);
// Dispatch the event
el.dispatchEvent(pasteEvent);
});
// Wait for compilation to complete after content change (if compiler exists)
cy.get('body').then(($body: any) => {
if ($body.find('.compiler-wrapper').length > 0) {
cy.get('.compiler-wrapper').should('not.have.class', 'compiling');
}
// If no compiler wrapper exists yet, that's fine - compilation will happen when compiler is added
});
}

View File

@@ -52,6 +52,10 @@ The explain button appears automatically when configured.
- Session-persistent consent and user preferences
- Reload button to bypass all caches
**Testing**:
- Comprehensive Cypress E2E tests covering UI interactions, consent flow, API mocking, caching behavior, and error handling
- Tests verify explain pane functionality, theme persistence, and proper handling of compilation states
## API Integration
**GET /** - Fetch available options:

View File

@@ -97,8 +97,6 @@ export class ExplainView extends Pane<ExplainViewState> {
private readonly explanationInfoButton: JQuery;
private readonly explainApiEndpoint: string;
private readonly fontScale: FontScale;
private readonly cache: LRUCache<string, ClaudeExplainResponse>;
// Use a static variable to persist consent across all instances during the session
private static consentGiven = false;
@@ -106,6 +104,9 @@ export class ExplainView extends Pane<ExplainViewState> {
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;
@@ -118,10 +119,14 @@ export class ExplainView extends Pane<ExplainViewState> {
super(hub, container, state);
this.explainApiEndpoint = options.explainApiEndpoint ?? '';
this.cache = new LRUCache({
maxSize: 200 * 1024,
sizeCalculation: n => JSON.stringify(n).length,
});
// 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');
@@ -465,7 +470,7 @@ export class ExplainView extends Pane<ExplainViewState> {
}
this.showSuccess();
this.cache.set(cacheKey, data);
ExplainView.cache!.set(cacheKey, data);
this.renderMarkdown(data.explanation);
this.showBottomBar();
@@ -505,7 +510,7 @@ export class ExplainView extends Pane<ExplainViewState> {
// Check cache first unless bypassing
if (!bypassCache) {
const cachedResult = this.cache.get(cacheKey);
const cachedResult = ExplainView.cache!.get(cacheKey);
if (cachedResult) {
this.displayCachedResult(cachedResult);
return;