diff --git a/e2e/tests/import-error.test.ts b/e2e/tests/import-error.test.ts new file mode 100644 index 0000000..ac64026 --- /dev/null +++ b/e2e/tests/import-error.test.ts @@ -0,0 +1,59 @@ +import { test, expect } from '../fixtures/extension.js'; +import { openPanelPage } from '../helpers/panel-page.js'; + +test.describe('import error handling', () => { + test('submitting invalid JSON shows error banner', async ({ extensionContext, extensionId }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + // Open import paste area + const importBtn = panelPage.locator('.tb-btn', { hasText: 'Import' }); + await importBtn.click(); + + const pasteArea = panelPage.locator('.import-paste-textarea'); + await expect(pasteArea).toBeVisible(); + + // Paste invalid JSON + await pasteArea.fill('not valid json {{{'); + const submitBtn = panelPage.locator('.import-paste .tb-btn', { hasText: 'Import' }); + await submitBtn.click(); + + // Error banner should appear + const errBanner = panelPage.locator('.err-banner'); + await expect(errBanner).toBeVisible({ timeout: 3000 }); + + // Panel should remain functional + await expect(panelPage.locator('.toolbar')).toBeVisible(); + + await panelPage.close(); + }); + + test('submitting valid JSON with wrong schema shows error banner', async ({ + extensionContext, + extensionId, + }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + // Open import paste area + const importBtn = panelPage.locator('.tb-btn', { hasText: 'Import' }); + await importBtn.click(); + + const pasteArea = panelPage.locator('.import-paste-textarea'); + await expect(pasteArea).toBeVisible(); + + // Paste valid JSON but wrong schema (missing required fields) + await pasteArea.fill(JSON.stringify({ version: 999, flow: {} })); + const submitBtn = panelPage.locator('.import-paste .tb-btn', { hasText: 'Import' }); + await submitBtn.click(); + + // Error banner should appear + const errBanner = panelPage.locator('.err-banner'); + await expect(errBanner).toBeVisible({ timeout: 3000 }); + + // Panel should remain functional + await expect(panelPage.locator('.toolbar')).toBeVisible(); + + await panelPage.close(); + }); +}); diff --git a/e2e/tests/payload-tab.test.ts b/e2e/tests/payload-tab.test.ts new file mode 100644 index 0000000..66c6753 --- /dev/null +++ b/e2e/tests/payload-tab.test.ts @@ -0,0 +1,69 @@ +import { test, expect } from '../fixtures/extension.js'; +import { openPanelPage } from '../helpers/panel-page.js'; +import { + injectDiscovery, + injectTokenRequest, + reloadAndWaitForEvents, +} from '../helpers/inject-events.js'; + +test.describe('payload tab', () => { + test('Payload tab appears for network events with request/response body', async ({ + extensionContext, + extensionId, + mockServer, + }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + // Inject discovery + token request (token has postData and JSON response body) + await injectDiscovery(panelPage, mockServer.baseUrl); + await injectTokenRequest(panelPage, mockServer.baseUrl); + await reloadAndWaitForEvents(panelPage, 2); + + // Select the token event (second row, after discovery) + await panelPage.locator('.tl-row').nth(1).click(); + + // Payload tab should be visible (event has requestBody + responseBody) + const payloadTab = panelPage.locator('.tab-btn', { hasText: 'Payload' }); + await expect(payloadTab).toBeVisible({ timeout: 3000 }); + + // Click the Payload tab + await payloadTab.click(); + await expect(payloadTab).toHaveClass(/active/); + + // Should show payload sections + await expect(panelPage.locator('.payload-section').first()).toBeVisible(); + await expect(panelPage.locator('.sect-hdr', { hasText: 'Request Body' })).toBeVisible(); + await expect(panelPage.locator('.sect-hdr', { hasText: 'Response Body' })).toBeVisible(); + + await panelPage.close(); + }); + + test('Payload tab for discovery events shows only Response Body section', async ({ + extensionContext, + extensionId, + mockServer, + }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + // Discovery is a GET request with no postData — only has responseBody + await injectDiscovery(panelPage, mockServer.baseUrl); + await injectTokenRequest(panelPage, mockServer.baseUrl); + await reloadAndWaitForEvents(panelPage, 2); + + // Select the discovery event (first row) — it's a GET with no postData + await panelPage.locator('.tl-row').first().click(); + + // Discovery has responseBody (the OIDC config JSON), so Payload tab + // should appear. But it has no requestBody, so only Response Body shows. + const payloadTab = panelPage.locator('.tab-btn', { hasText: 'Payload' }); + await expect(payloadTab).toBeVisible({ timeout: 3000 }); + await payloadTab.click(); + await expect(payloadTab).toHaveClass(/active/); + await expect(panelPage.locator('.sect-hdr', { hasText: 'Response Body' })).toBeVisible(); + await expect(panelPage.locator('.sect-hdr', { hasText: 'Request Body' })).not.toBeVisible(); + + await panelPage.close(); + }); +}); diff --git a/e2e/tests/theme-toggle.test.ts b/e2e/tests/theme-toggle.test.ts new file mode 100644 index 0000000..1138bcc --- /dev/null +++ b/e2e/tests/theme-toggle.test.ts @@ -0,0 +1,122 @@ +import { test, expect } from '../fixtures/extension.js'; +import { openPanelPage } from '../helpers/panel-page.js'; + +test.describe('theme toggle', () => { + test('theme toggle button is visible in toolbar', async ({ extensionContext, extensionId }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + // The theme toggle is appended by JS after Elm renders the toolbar + const toggleBtn = panelPage.locator('.theme-toggle'); + await expect(toggleBtn).toBeVisible({ timeout: 3000 }); + + await panelPage.close(); + }); + + test('clicking toggle switches to light mode and back', async ({ + extensionContext, + extensionId, + }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + const toggleBtn = panelPage.locator('.theme-toggle'); + await expect(toggleBtn).toBeVisible({ timeout: 3000 }); + + // Get initial theme state + const initialTheme = await panelPage.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ); + + // Click to switch theme + await toggleBtn.click(); + const afterFirstClick = await panelPage.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ); + + if (initialTheme === null) { + expect(afterFirstClick).toBe('light'); + } else { + expect(afterFirstClick).toBeNull(); + } + + // Click again to toggle back + await toggleBtn.click(); + const afterSecondClick = await panelPage.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ); + expect(afterSecondClick).toBe(initialTheme); + + await panelPage.close(); + }); + + test('theme preference persists across page reloads', async ({ + extensionContext, + extensionId, + }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + const toggleBtn = panelPage.locator('.theme-toggle'); + await expect(toggleBtn).toBeVisible({ timeout: 3000 }); + + // Get initial state + const initialTheme = await panelPage.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ); + + // Toggle the theme + await toggleBtn.click(); + const newTheme = await panelPage.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ); + expect(newTheme).not.toBe(initialTheme); + + // Verify localStorage was updated + const storedTheme = await panelPage.evaluate(() => localStorage.getItem('wolfcola:theme')); + expect(storedTheme).toBe(newTheme === 'light' ? 'light' : 'dark'); + + // Reload and verify theme persists + await panelPage.reload(); + await panelPage.waitForSelector('.toolbar', { state: 'visible' }); + + const themeAfterReload = await panelPage.evaluate(() => + document.documentElement.getAttribute('data-theme'), + ); + expect(themeAfterReload).toBe(newTheme); + + await panelPage.close(); + }); + + test('toggle button survives Elm re-render', async ({ extensionContext, extensionId }) => { + const panelPage = await extensionContext.newPage(); + await openPanelPage(panelPage, extensionId); + + await expect(panelPage.locator('.theme-toggle')).toBeVisible({ timeout: 3000 }); + + // Trigger an Elm re-render by injecting an SDK event + await panelPage.evaluate(() => { + chrome.runtime.sendMessage({ + type: 'SDK_EVENT', + payload: { + type: 'sdk:node-change', + id: `theme-test-${Date.now()}`, + flowId: 'theme-flow', + timestamp: Date.now(), + source: 'sdk', + causedBy: null, + data: { _tag: 'sdk', nodeStatus: 'continue' }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }, + }); + }); + + // Wait for the event to appear (forces Elm re-render) + await panelPage.waitForSelector('.tl-row', { state: 'visible', timeout: 5000 }); + + // Toggle should still be there after Elm re-rendered + await expect(panelPage.locator('.theme-toggle')).toBeVisible({ timeout: 3000 }); + + await panelPage.close(); + }); +}); diff --git a/packages/devtools-extension/src/panel/panel.ts b/packages/devtools-extension/src/panel/panel.ts index 4ef8bee..4c4c9a0 100644 --- a/packages/devtools-extension/src/panel/panel.ts +++ b/packages/devtools-extension/src/panel/panel.ts @@ -129,6 +129,13 @@ function initThemeToggle() { } }); observer.observe(document.body, { childList: true, subtree: true }); + + // Eagerly append if the toolbar already exists (Elm.Main.init runs before + // initThemeToggle, so the initial render mutation has already fired). + const toolbar = document.querySelector('.toolbar'); + if (toolbar) { + toolbar.appendChild(btn); + } } // ── App init ────────────────────────────────────────────────────────────────── diff --git a/packages/devtools-ui/src/panel.css b/packages/devtools-ui/src/panel.css index 2aa31af..023dc29 100644 --- a/packages/devtools-ui/src/panel.css +++ b/packages/devtools-ui/src/panel.css @@ -1133,7 +1133,7 @@ details[open] > .jwt-summary::before { top: 0; left: var(--graph-w); width: 5px; - height: 100%; + height: calc(100% - var(--insp-h, 220px)); cursor: col-resize; transform: translateX(-2px); }