From 50ec53d0e79021389d761b964d1b4b6662090fdf Mon Sep 17 00:00:00 2001 From: Matt Godbolt Date: Tue, 5 Aug 2025 16:42:48 -0500 Subject: [PATCH] Add comprehensive Cypress E2E tests for Claude Explain feature (#7751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- cypress/README.md | 165 +++++++++ cypress/e2e/claude-explain.cy.ts | 613 +++++++++++++++++++++++++++++++ cypress/e2e/monaco-test.cy.ts | 68 ++++ cypress/support/utils.ts | 52 +++ docs/ClaudeExplain.md | 6 +- static/panes/explain-view.ts | 21 +- 6 files changed, 916 insertions(+), 9 deletions(-) create mode 100644 cypress/README.md create mode 100644 cypress/e2e/claude-explain.cy.ts create mode 100644 cypress/e2e/monaco-test.cy.ts diff --git a/cypress/README.md b/cypress/README.md new file mode 100644 index 000000000..060a385c9 --- /dev/null +++ b/cypress/README.md @@ -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 \ No newline at end of file diff --git a/cypress/e2e/claude-explain.cy.ts b/cypress/e2e/claude-explain.cy.ts new file mode 100644 index 000000000..5fe2f8493 --- /dev/null +++ b/cypress/e2e/claude-explain.cy.ts @@ -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'); + }); + }); + }); +}); diff --git a/cypress/e2e/monaco-test.cy.ts b/cypress/e2e/monaco-test.cy.ts new file mode 100644 index 000000000..596736cbb --- /dev/null +++ b/cypress/e2e/monaco-test.cy.ts @@ -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 +#include + +int main() { + std::vector 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'); + }); +}); diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index d38dfbe37..46d5710ff 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -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 + }); +} diff --git a/docs/ClaudeExplain.md b/docs/ClaudeExplain.md index d6f641555..3a3ecf426 100644 --- a/docs/ClaudeExplain.md +++ b/docs/ClaudeExplain.md @@ -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: @@ -113,4 +117,4 @@ Response: - May not explain every compiler optimization or assembly pattern - Large assemblies may be truncated before sending to API - Requires internet connection for external API access -- One explain view per compiler at a time +- One explain view per compiler at a time \ No newline at end of file diff --git a/static/panes/explain-view.ts b/static/panes/explain-view.ts index 3ad928500..b533868cf 100644 --- a/static/panes/explain-view.ts +++ b/static/panes/explain-view.ts @@ -97,8 +97,6 @@ export class ExplainView extends Pane { private readonly explanationInfoButton: JQuery; private readonly explainApiEndpoint: string; private readonly fontScale: FontScale; - private readonly cache: LRUCache; - // 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 { private static availableOptions: AvailableOptions | null = null; private static optionsFetchPromise: Promise | null = null; + // Static explanation cache shared across all instances (200KB limit) + private static cache: LRUCache | null = null; + // Instance variables for selected options private selectedAudience: string; private selectedExplanation: string; @@ -118,10 +119,14 @@ export class ExplainView extends Pane { 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 { } 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 { // 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;