diff --git a/packages/bruno-app/src/components/AppView/StyledWrapper.js b/packages/bruno-app/src/components/AppView/StyledWrapper.js new file mode 100644 index 000000000..5355c7f86 --- /dev/null +++ b/packages/bruno-app/src/components/AppView/StyledWrapper.js @@ -0,0 +1,52 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + flex-grow: 1; + padding: 0.5rem; + + .app-view-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.25rem 0.4rem; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .app-view-toolbar .app-exit-btn { + cursor: pointer; + padding: 2px 8px; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 3px; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + + &:hover { + color: ${(props) => props.theme.text}; + border-color: ${(props) => props.theme.text}; + } + } + + .app-webview-container { + flex: 1 1 0; + min-height: 0; + display: flex; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 4px; + overflow: hidden; + background: ${(props) => props.theme.background.surface0}; + } + + .app-webview { + width: 100%; + height: 100%; + flex: 1 1 0; + border: 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/AppView/index.js b/packages/bruno-app/src/components/AppView/index.js new file mode 100644 index 000000000..af9c682fe --- /dev/null +++ b/packages/bruno-app/src/components/AppView/index.js @@ -0,0 +1,399 @@ +import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { useDispatch } from 'react-redux'; +import { sendNetworkRequest } from 'utils/network/index'; +import { + findEnvironmentInCollection, + getEnvironmentVariables, + getGlobalEnvironmentVariables +} from 'utils/collections'; +import { responseReceived, appSetRuntimeVariable, toggleAppMode, initRunRequestEvent } from 'providers/ReduxStore/slices/collections'; +import { uuid } from 'utils/common'; +import { useTheme } from 'providers/Theme'; +import StyledWrapper from './StyledWrapper'; + +/* + * App content runs inside an Electron , which is an out-of-process guest + * with its own document, so it does NOT inherit the app's strict CSP (script-src 'self') + * This mirrors the HtmlPreview component used for HTML response previews. + * + * Messaging (no node integration in the guest, so postMessage/ipc aren't available): + * host -> guest : webview.executeJavaScript(`window.__brunoReceive()`) + * guest -> host : console.log(SENTINEL + json), read via the 'console-message' event + */ +const SENTINEL = '__BRUNO_APP_MSG__'; + +// Encode a value for safe inlining into an executeJavaScript() string as a JS object literal. +const toJsArg = (value) => + JSON.stringify(value === undefined ? null : value) + .replace(/ +(function () { + if (window.__brunoBootstrapped) return; + window.__brunoBootstrapped = true; + + var SENTINEL = ${JSON.stringify(SENTINEL)}; + var pending = new Map(); + var nextRequestId = 0; + + function sendToHost(payload) { + try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {} + } + + var ctx = { + theme: 'light', + response: null, + assertionResults: [], + testResults: [], + variables: {}, + + onThemeChange: null, + onResponseUpdate: null, + onResultsUpdate: null, + onVariablesUpdate: null, + + sendRequest: function (overrides) { + return new Promise(function (resolve, reject) { + var requestId = ++nextRequestId; + pending.set(requestId, { resolve: resolve, reject: reject }); + sendToHost({ type: 'sendRequest', requestId: requestId, overrides: overrides || {} }); + }); + }, + setRuntimeVariable: function (key, value) { + sendToHost({ type: 'setRuntimeVariable', key: String(key), value: value }); + }, + log: function () { + var args = Array.prototype.slice.call(arguments); + sendToHost({ type: 'log', args: args }); + } + }; + window.ctx = ctx; + + function applyTheme(theme) { + ctx.theme = theme || 'light'; + if (document.body) { + document.body.classList.remove('light', 'dark'); + document.body.classList.add(ctx.theme); + } + } + + // Host -> guest entry point. + window.__brunoReceive = function (msg) { + if (!msg) return; + switch (msg.type) { + case 'state': + applyTheme(msg.theme); + ctx.response = msg.response || null; + ctx.assertionResults = msg.assertionResults || []; + ctx.testResults = msg.testResults || []; + ctx.variables = msg.variables || {}; + break; + case 'theme': + applyTheme(msg.theme); + if (typeof ctx.onThemeChange === 'function') ctx.onThemeChange(ctx.theme); + break; + case 'responseUpdate': + ctx.response = msg.response || null; + if (typeof ctx.onResponseUpdate === 'function') ctx.onResponseUpdate(ctx.response); + break; + case 'results': + ctx.assertionResults = msg.assertionResults || []; + ctx.testResults = msg.testResults || []; + if (typeof ctx.onResultsUpdate === 'function') { + ctx.onResultsUpdate({ assertionResults: ctx.assertionResults, testResults: ctx.testResults }); + } + break; + case 'variables': + ctx.variables = msg.variables || {}; + if (typeof ctx.onVariablesUpdate === 'function') ctx.onVariablesUpdate(ctx.variables); + break; + case 'response': { + var entry = pending.get(msg.requestId); + if (!entry) return; + pending.delete(msg.requestId); + if (msg.error) entry.reject(new Error(msg.error)); + else entry.resolve(msg.response); + break; + } + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { sendToHost({ type: 'ready' }); }); + } else { + sendToHost({ type: 'ready' }); + } +})(); +`; + +const FRAGMENT_STYLES = ``; + +// User code may be a full HTML document or a fragment. For a full document we inject +// the bootstrap into it (avoids producing a malformed nested document); a fragment is +// wrapped in a minimal shell. +const generateAppHtml = (userCode) => { + const code = userCode || ''; + const isFullDocument = /]/i.test(code) || /]*>/i.test(code)) { + return code.replace(/]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`); + } + if (/]*>/i.test(code)) { + return code.replace(/]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`); + } + return `${BOOTSTRAP_SCRIPT}${code}`; + } + + return ` + + + + + ${FRAGMENT_STYLES} + ${BOOTSTRAP_SCRIPT} + + +${code} + +`; +}; + +const serializeTimeline = (timeline) => { + if (!Array.isArray(timeline)) return timeline; + return timeline.map((entry) => ({ + ...entry, + timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp + })); +}; + +const projectResponse = (r) => ({ + status: r?.status ?? null, + statusText: r?.statusText ?? null, + data: r?.data ?? null, + headers: r?.headers ?? null, + duration: r?.duration ?? null, + size: r?.size ?? null +}); + +const buildVariables = (collection) => { + const env = getEnvironmentVariables(collection); + const global = getGlobalEnvironmentVariables({ + globalEnvironments: collection?.globalEnvironments || [], + activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid + }); + return { + ...global, + ...env, + ...(collection?.collectionVariables || {}), + ...(collection?.runtimeVariables || {}) + }; +}; + +const AppView = ({ item, collection, code }) => { + const dispatch = useDispatch(); + const { displayedTheme } = useTheme(); + const webviewRef = useRef(null); + const [domReady, setDomReady] = useState(false); + + const src = useMemo( + () => `data:text/html;charset=utf-8,${encodeURIComponent(generateAppHtml(code || ''))}`, + [code] + ); + + const environment = useMemo( + () => findEnvironmentInCollection(collection, collection.activeEnvironmentUid), + [collection] + ); + const variables = useMemo(() => buildVariables(collection), [collection]); + const response = useMemo(() => (item.response ? projectResponse(item.response) : null), [item.response]); + const assertionResults = useMemo(() => item.assertionResults || [], [item.assertionResults]); + const testResults = useMemo(() => item.testResults || [], [item.testResults]); + + // Push a message into the guest. Safe to call before dom-ready (no-op until then). + const pushToGuest = useCallback((msg) => { + const webview = webviewRef.current; + if (!webview || !domReady) return; + try { + webview.executeJavaScript(`window.__brunoReceive && window.__brunoReceive(${toJsArg(msg)})`).catch(() => {}); + } catch (_) { + /* webview not attached yet */ + } + }, [domReady]); + + const handleSendRequest = useCallback( + async (requestId, overrides) => { + try { + // Mint a requestUid and register the run so the main process emits its + // test/assertion/script events against an id the store recognises — this is + // what makes ctx.testResults / ctx.assertionResults populate (same as Send). + const requestUid = uuid(); + const requestItem = cloneDeep(item.draft || item); + requestItem.requestUid = requestUid; + dispatch(initRunRequestEvent({ requestUid, itemUid: item.uid, collectionUid: collection.uid })); + + const flatOverrides = overrides && typeof overrides === 'object' ? { ...overrides } : {}; + const explicitVars = flatOverrides.variables; + delete flatOverrides.variables; + const mergedRuntime = { + ...(collection.runtimeVariables || {}), + ...flatOverrides, + ...(explicitVars && typeof explicitVars === 'object' ? explicitVars : {}) + }; + + const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime); + + // sendNetworkRequest resolves (rather than rejects) on network/request + // errors with an `error` payload — surface that to the guest as a rejection. + if (result?.error) { + const errorMessage = typeof result.error === 'string' + ? result.error + : result.error?.message || 'Request failed'; + pushToGuest({ type: 'response', requestId, error: errorMessage }); + return; + } + + dispatch( + responseReceived({ + itemUid: item.uid, + collectionUid: collection.uid, + response: { + status: result.status, + statusText: result.statusText, + headers: result.headers, + data: result.data, + dataBuffer: result.dataBuffer, + size: result.size, + duration: result.duration, + timeline: serializeTimeline(result.timeline) + } + }) + ); + + pushToGuest({ type: 'response', requestId, response: projectResponse(result) }); + } catch (err) { + pushToGuest({ type: 'response', requestId, error: err?.message || 'Request failed' }); + } + }, + [item, collection, environment, dispatch, pushToGuest] + ); + + const handleGuestMessage = useCallback( + (data) => { + switch (data?.type) { + case 'ready': + // Readiness is tracked via the webview 'dom-ready' event; nothing to do here. + break; + case 'sendRequest': + handleSendRequest(data.requestId, data.overrides); + break; + case 'setRuntimeVariable': + if (typeof data.key === 'string' && data.key.length) { + dispatch(appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value })); + } + break; + case 'log': + console.log('[app]', ...(data.args || [])); + break; + default: + break; + } + }, + [handleSendRequest, dispatch, collection.uid] + ); + + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const onConsoleMessage = (e) => { + const text = e?.message; + if (typeof text !== 'string' || !text.startsWith(SENTINEL)) return; + try { + handleGuestMessage(JSON.parse(text.slice(SENTINEL.length))); + } catch (_) { + /* not our message */ + } + }; + // executeJavaScript() is only valid after Electron's 'dom-ready'; gate on that. + // A reload (e.g. code change) tears the guest down, so reset readiness then. + const onDomReady = () => setDomReady(true); + const onStartLoading = () => setDomReady(false); + + webview.addEventListener('console-message', onConsoleMessage); + webview.addEventListener('dom-ready', onDomReady); + webview.addEventListener('did-start-loading', onStartLoading); + + return () => { + webview.removeEventListener('console-message', onConsoleMessage); + webview.removeEventListener('dom-ready', onDomReady); + webview.removeEventListener('did-start-loading', onStartLoading); + }; + }, [handleGuestMessage]); + + // Push initial state once the guest signals ready (also after a reload). + // Push a full state snapshot on the readiness transition (initial load and after reloads). + // Subsequent changes are handled by the granular effects below. + const stateRef = useRef(); + stateRef.current = { theme: displayedTheme, response, assertionResults, testResults, variables }; + useEffect(() => { + if (!domReady) return; + pushToGuest({ type: 'state', ...stateRef.current }); + }, [domReady, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'theme', theme: displayedTheme }); + }, [displayedTheme, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'responseUpdate', response }); + }, [response, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'results', assertionResults, testResults }); + }, [assertionResults, testResults, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'variables', variables }); + }, [variables, pushToGuest]); + + const disableApp = useCallback(() => { + dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid })); + }, [dispatch, item.uid, collection.uid]); + + return ( + +
+ App mode - {item.name} + +
+
+ +
+
+ ); +}; + +export default AppView; diff --git a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js new file mode 100644 index 000000000..59ff15653 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .app-toggle-row { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + + .app-editor { + div.CodeMirror { + height: inherit; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js new file mode 100644 index 000000000..320d72761 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js @@ -0,0 +1,55 @@ +import React from 'react'; +import get from 'lodash/get'; +import { useDispatch, useSelector } from 'react-redux'; +import CodeEditor from 'components/CodeEditor'; +import ToggleSwitch from 'components/ToggleSwitch'; +import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import StyledWrapper from './StyledWrapper'; + +const AppCodeEditor = ({ item, collection }) => { + const dispatch = useDispatch(); + const { displayedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + + const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', ''); + const enabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false); + + const onEdit = (value) => + dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })); + + const onToggle = () => + dispatch(toggleAppMode({ enabled: !enabled, itemUid: item.uid, collectionUid: collection.uid })); + + const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + return ( + +
+
+ +

+ When enabled, replaces the request/response panes with the app view for this request. +

+
+ +
+ +
+ +
+
+ ); +}; + +export default AppCodeEditor; diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index a0621adb2..596bd9e71 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -13,6 +13,7 @@ import Assertions from 'components/RequestPane/Assertions'; import Script from 'components/RequestPane/Script'; import Tests from 'components/RequestPane/Tests'; import Settings from 'components/RequestPane/Settings'; +import AppCodeEditor from 'components/RequestPane/AppCodeEditor'; import Documentation from 'components/Documentation/index'; import StatusDot from 'components/StatusDot'; import ResponsiveTabs from 'ui/ResponsiveTabs'; @@ -30,6 +31,7 @@ const TAB_CONFIG = [ { key: 'assert', label: 'Assert' }, { key: 'tests', label: 'Tests' }, { key: 'docs', label: 'Docs' }, + { key: 'app', label: 'App' }, { key: 'settings', label: 'Settings' } ]; @@ -43,6 +45,7 @@ const TAB_PANELS = { script: Script, tests: Tests, docs: Documentation, + app: AppCodeEditor, settings: Settings }; @@ -71,6 +74,7 @@ const HttpRequestPane = ({ item, collection }) => { const responseVars = getProperty('request.vars.res'); const auth = getProperty('request.auth'); const tags = getProperty('tags'); + const app = item.draft ? get(item, 'draft.app') : get(item, 'app'); const activeCounts = useMemo(() => ({ params: params.filter((p) => p.enabled).length, @@ -106,9 +110,10 @@ const HttpRequestPane = ({ item, collection }) => { assert: activeCounts.assertions > 0 ? {activeCounts.assertions} : null, tests: tests?.length > 0 ? (hasTestError ? : ) : null, docs: docs?.length > 0 ? : null, + app: app?.code?.length > 0 ? : null, settings: tags?.length > 0 ? : null }; - }, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]); + }, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, app, tags]); const allTabs = useMemo( () => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })), diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 50af42aaa..359e962be 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import find from 'lodash/find'; +import get from 'lodash/get'; import toast from 'react-hot-toast'; import { useSelector, useDispatch } from 'react-redux'; import GraphQLRequestPane from 'components/RequestPane/GraphQLRequestPane'; @@ -20,6 +21,7 @@ import CollectionSettings from 'components/CollectionSettings'; import { DocExplorer } from '@usebruno/graphql-docs'; import FileEditor from 'components/FileEditor'; +import AppView from 'components/AppView'; import StyledWrapper from './StyledWrapper'; import FolderSettings from 'components/FolderSettings'; import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index'; @@ -493,6 +495,18 @@ const RequestTabPanel = () => { ); } + const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false); + if (appEnabled) { + const appCode = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', ''); + return ( + + + + + + ); + } + const renderQueryUrl = () => { if (isGrpcRequest) { return ; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js index 599c773ac..e2947d265 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -215,6 +215,44 @@ const StyledWrapper = styled.div` border-radius: ${(props) => props.theme.border.radius.sm} } } + + .mode-toggle { + display: flex; + align-items: center; + height: 24px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.base}; + overflow: hidden; + + .mode-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 100%; + padding: 0; + border: none; + border-right: 1px solid ${(props) => props.theme.input.border}; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + + &:last-child { + border-right: none; + } + + &:hover:not(.active) { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &.active { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.primary.text}; + } + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 0744ace4e..0b28f5586 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -15,13 +15,18 @@ import { IconUpload, IconFileCode, IconFileOff, + IconCode, + IconApps, IconTransform } from '@tabler/icons'; import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces'; import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; -import { toggleCollectionFileMode, updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; +import { toggleCollectionFileMode, toggleAppMode, updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; +import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections'; +import find from 'lodash/find'; +import get from 'lodash/get'; import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; import { uuid } from 'utils/common'; import toast from 'react-hot-toast'; @@ -58,11 +63,29 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); const collections = useSelector((state) => state.collections.collections); const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); // Get the current active workspace const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const gitRootPath = collection?.git?.gitRootPath; + // Active request (used by the Request / App / File view-mode toggle) + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const activeItem = focusedTab && collection + ? (findItemInCollection(collection, activeTabUid) + || (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null)) + : null; + const isHttpRequestActive = activeItem?.type === 'http-request'; + const appEnabled = activeItem + ? (activeItem.draft ? get(activeItem, 'draft.app.enabled', false) : get(activeItem, 'app.enabled', false)) + : false; + + const handleToggleAppMode = (enabled) => { + if (isHttpRequestActive) { + dispatch(toggleAppMode({ enabled, itemUid: activeItem.uid, collectionUid: collection.uid })); + } + }; + // Workspace rename state const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false); const [workspaceNameInput, setWorkspaceNameInput] = useState(''); @@ -277,7 +300,11 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { // Build overflow menu items for the "..." dropdown const overflowMenuItems = [ { id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables }, - { id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick }, + // File mode is exposed via the Request/App/File view-mode toggle when a request is active; + // keep it in the overflow as a fallback for non-request contexts. + ...(!isHttpRequestActive + ? [{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick }] + : []), ...(!hasOpenApiSyncConfigured ? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }] : []), @@ -630,6 +657,48 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { {/* Right side: Actions (only for regular collections) */} {!isScratchCollection && (
+ {isHttpRequestActive && ( + +
+ + + +
+
+ )} {collection.format === 'bru' && !migratePillDismissed && (
{ + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, action.payload.itemUid); + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.app = item.draft.app || {}; + item.draft.app.code = action.payload.code; + } + }, + toggleAppMode: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, action.payload.itemUid); + if (item && isItemARequest(item)) { + item.app = item.app || {}; + item.app.enabled = action.payload.enabled; + if (item.draft) { + item.draft.app = item.draft.app || {}; + item.draft.app.enabled = action.payload.enabled; + } + } + }, + appSetRuntimeVariable: (state, action) => { + const { collectionUid, key, value } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + collection.runtimeVariables = { ...(collection.runtimeVariables || {}), [key]: value }; + }, updateFolderDocs: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; @@ -3961,6 +4008,9 @@ export const { updateFolderDocs, toggleCollectionFileMode, updateFileContent, + updateAppCode, + toggleAppMode, + appSetRuntimeVariable, moveCollection, streamDataReceived, collectionAddOauth2CredentialsByUrl, diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 8599b3ba1..73eeba8af 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -704,6 +704,10 @@ export const transformRequestToSaveToFilesystem = (item) => { })); }; + const appToSave = _item.app && _item.app.code && _item.app.code.length + ? { code: _item.app.code } + : null; + const itemToSave = { uid: _item.uid, type: _item.type, @@ -711,6 +715,7 @@ export const transformRequestToSaveToFilesystem = (item) => { seq: _item.seq, settings: _item.settings, tags: _item.tags, + app: appToSave, examples: transformExamples(_item.examples || []), request: { method: _item.request.method, diff --git a/packages/bruno-filestore/src/formats/bru/index.ts b/packages/bruno-filestore/src/formats/bru/index.ts index db89329a0..100e443ff 100644 --- a/packages/bruno-filestore/src/formats/bru/index.ts +++ b/packages/bruno-filestore/src/formats/bru/index.ts @@ -38,11 +38,16 @@ export const parseBruRequest = (data: string | any, parsed: boolean = false): an 'ws-request': 'ws.url', 'default': 'http.url' }; + + const appData = _.get(json, 'app'); + const app = appData ? { code: _.get(appData, 'code', null) } : null; + const transformedJson = { type: requestType, name: _.get(json, 'meta.name'), seq: !_.isNaN(sequence) ? Number(sequence) : 1, settings: _.get(json, 'settings', {}), + app, tags: Array.isArray(tags) ? tags : [], request: { // Preserving special characters in custom methods. Using _.upperCase strips special characters. @@ -221,6 +226,11 @@ export const stringifyBruRequest = (json: any): string => { bruJson.docs = _.get(json, 'request.docs', ''); bruJson.examples = _.get(json, 'examples', []).map((e: any) => jsonExampleToBru(e)); + const app = _.get(json, 'app'); + if (app && app.code && app.code.length) { + bruJson.app = { code: app.code }; + } + const bru = jsonToBruV2(bruJson); return bru; } catch (error) { diff --git a/packages/bruno-filestore/src/formats/yml/common/app.ts b/packages/bruno-filestore/src/formats/yml/common/app.ts new file mode 100644 index 000000000..403da7875 --- /dev/null +++ b/packages/bruno-filestore/src/formats/yml/common/app.ts @@ -0,0 +1,18 @@ +import type { App as BrunoApp } from '@usebruno/schema-types/collection/item'; + +export interface OpenCollectionApp { + code?: string; +} + +export const toOpenCollectionApp = (app: BrunoApp | null | undefined): OpenCollectionApp | undefined => { + if (!app || !app.code) return undefined; + return { code: app.code }; +}; + +export const toBrunoApp = (app: OpenCollectionApp | null | undefined): BrunoApp | null => { + if (!app) return null; + return { + enabled: false, + code: app.code || null + }; +}; diff --git a/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts index e734db994..ce502af10 100644 --- a/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts @@ -9,6 +9,7 @@ import { toBrunoVariables } from '../common/variables'; import { toBrunoPostResponseVariables } from '../common/actions'; import { toBrunoScripts } from '../common/scripts'; import { toBrunoAssertions } from '../common/assertions'; +import { toBrunoApp } from '../common/app'; import { uuid, ensureString } from '../../../utils'; const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => { @@ -79,6 +80,9 @@ const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => { brunoRequest.docs = ocRequest.docs; } + // app + const app = toBrunoApp((ocRequest as any).app); + // bruno item const brunoItem: BrunoItem = { uid: uuid(), @@ -88,6 +92,7 @@ const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => { tags: info?.tags || [], request: brunoRequest, settings: null, + app, fileContent: null, root: null, items: [], diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts index 35cb03eca..a23305b4b 100644 --- a/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/stringifyHttpRequest.ts @@ -8,6 +8,7 @@ import type { Assertion } from '@opencollection/types/common/assertions'; import type { Action } from '@opencollection/types/common/actions'; import type { HttpRequestParam, HttpRequestBody } from '@opencollection/types/requests/http'; import { stringifyYml } from '../utils'; +import { toOpenCollectionApp, OpenCollectionApp } from '../common/app'; import { toOpenCollectionAuth } from '../common/auth'; import { toOpenCollectionHttpHeaders, toOpenCollectionResponseHeaders } from '../common/headers'; import { toOpenCollectionParams } from '../common/params'; @@ -212,6 +213,12 @@ const stringifyHttpRequest = (item: BrunoItem): string => { ocRequest.docs = brunoRequest.docs; } + // app + const app: OpenCollectionApp | undefined = toOpenCollectionApp(item.app); + if (app) { + (ocRequest as any).app = app; + } + return stringifyYml(ocRequest); } catch (error) { console.error('Error stringifying HTTP request:', error); diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 106cd55ca..f62ad9737 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -34,7 +34,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); * */ const grammar = ohm.grammar(`Bru { - BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | settings | docs | example)* + BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | app | settings | docs | example)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws bodyforms = bodyformurlencoded | bodymultipart | bodyfile @@ -108,6 +108,7 @@ const grammar = ohm.grammar(`Bru { listitem = st+ (alnum | "_" | "-")+ st* meta = "meta" dictionary + app = "app" dictionary settings = "settings" dictionary http = get | post | put | delete | patch | options | head | connect | trace | httpcustom @@ -522,6 +523,14 @@ const sem = grammar.createSemantics().addAttribute('ast', { meta }; }, + app(_1, dictionary) { + const appData = mapPairListToKeyValPair(dictionary.ast); + return { + app: { + code: appData.code || null + } + }; + }, settings(_1, dictionary) { let settings = mapPairListToKeyValPair(dictionary.ast); const getNumFromRecord = createGetNumFromRecord(settings); diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index f78e756c9..654c02f34 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -14,7 +14,7 @@ const stripLastLine = (text) => { }; const jsonToBru = (json) => { - const { meta, http, grpc, ws, params, headers, metadata, auth, body, script, tests, vars, assertions, settings, docs, examples } = json; + const { meta, http, grpc, ws, params, headers, metadata, auth, body, script, tests, vars, assertions, settings, app, docs, examples } = json; let bru = ''; @@ -757,6 +757,12 @@ ${indentString(tests)} `; } + if (app && app.code && app.code.length) { + bru += `app {\n`; + bru += ` code: '''\n${indentString(app.code, 2)}\n '''`; + bru += '\n}\n\n'; + } + if (settings && Object.keys(settings).length) { bru += 'settings {\n'; for (const key in settings) { diff --git a/packages/bruno-schema-types/src/collection/item.ts b/packages/bruno-schema-types/src/collection/item.ts index bdf3b3346..00aa1dabf 100644 --- a/packages/bruno-schema-types/src/collection/item.ts +++ b/packages/bruno-schema-types/src/collection/item.ts @@ -27,6 +27,11 @@ export interface WebSocketItemSettings { export type ItemSettings = HttpItemSettings | WebSocketItemSettings | null; +export interface App { + code?: string | null; + enabled?: boolean | null; +} + export interface Item { uid: UID; type: ItemType; @@ -35,6 +40,7 @@ export interface Item { tags?: string[] | null; request?: Request | null; settings?: ItemSettings; + app?: App | null; fileContent?: string | null; root?: FolderRoot | null; items?: Item[] | null; diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index f93121a76..a997c96a4 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -684,6 +684,11 @@ const itemSchema = Yup.object({ then: (schema) => schema.nullable(), otherwise: Yup.array().strip() }), + app: Yup.object({ + code: Yup.string().nullable() + }) + .noUnknown(true) + .nullable(), filename: Yup.string().nullable(), pathname: Yup.string().nullable() }) diff --git a/tests/apps/apps-ctx-api.spec.ts b/tests/apps/apps-ctx-api.spec.ts new file mode 100644 index 000000000..44ce96503 --- /dev/null +++ b/tests/apps/apps-ctx-api.spec.ts @@ -0,0 +1,212 @@ +import { test, expect, ElectronApplication } from '../../playwright'; +import { + createCollection, + createRequest, + openRequest, + setAppCode, + enableApp, + saveRequest, + selectRequestBodyMode, + getAppWebviewHtml +} from '../utils/page'; + +/* + * The app runs inside an out-of-process guest, so we can't reach it + * through the renderer page. Instead we evaluate in the Electron main process, + * locate the guest WebContents, and run JS inside it. + */ +const guestEval = (electronApp: ElectronApplication, code: string) => + electronApp.evaluate(async ({ webContents }, c) => { + // The app view loads from a data:text/html URL. Filtering on that keeps us + // bound to the app guest even if other webviews (e.g. HTML response preview) + // are present. + const guest = webContents.getAllWebContents().find((wc) => { + try { + return wc.getType() === 'webview' && (wc.getURL() || '').startsWith('data:text/html'); + } catch { + return false; + } + }); + if (!guest) return undefined; + return await guest.executeJavaScript(c, true); + }, code); + +const waitForGuestReady = async (electronApp: ElectronApplication) => { + await expect + .poll(async () => guestEval(electronApp, 'typeof window.ctx'), { timeout: 15000 }) + .toBe('object'); +}; + +const guestResult = (electronApp: ElectronApplication) => + guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`); + +// A fragment app exposing helpers the host-side test can invoke in the guest. +// It echoes the resolved `q` field from the response body into `#out[data-result]`. +const CTX_APP = ` +
pending
+`; + +// POST to the echo endpoint with a templated JSON body so an overridden `q` +// runtime variable round-trips back in the response. (Uses /api/echo/json, +// the same endpoint the rest of the suite relies on.) +const ECHO_JSON_URL = 'http://localhost:8081/api/echo/json'; + +// Set the JSON request body via the CodeMirror API — typing `{{q}}` would trip +// auto-close-bracket handling. +const setJsonBodyWithVar = async (page) => { + await selectRequestBodyMode(page, 'JSON'); + const editor = page.getByTestId('request-body-editor').locator('.CodeMirror').first(); + await editor.waitFor({ state: 'visible' }); + await editor.evaluate((el) => { + const cm = (el as any).CodeMirror; + if (cm) cm.setValue('{"q":"{{q}}"}'); + }); +}; + +test.describe('Apps - ctx API', () => { + test('exposes the full ctx surface inside the guest', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ctx-surface'); + await createCollection(page, 'apps-ctx', collectionPath); + await createRequest(page, 'ctx-req', 'apps-ctx', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-ctx', 'ctx-req', { persist: true }); + + await setAppCode(page, CTX_APP); + await enableApp(page); + await waitForGuestReady(electronApp); + + const raw = await guestEval( + electronApp, + `JSON.stringify({ + ctx: typeof window.ctx, + sendRequest: typeof window.ctx.sendRequest, + setRuntimeVariable: typeof window.ctx.setRuntimeVariable, + log: typeof window.ctx.log, + variablesIsObject: !!(window.ctx.variables && typeof window.ctx.variables === 'object'), + hooks: ['onThemeChange','onResponseUpdate','onResultsUpdate','onVariablesUpdate'].filter(function (k) { return k in window.ctx; }) + })` + ); + const surface = JSON.parse(raw as string); + + expect(surface.ctx).toBe('object'); + expect(surface.sendRequest).toBe('function'); + expect(surface.setRuntimeVariable).toBe('function'); + expect(surface.log).toBe('function'); + expect(surface.variablesIsObject).toBe(true); + expect(surface.hooks).toEqual(['onThemeChange', 'onResponseUpdate', 'onResultsUpdate', 'onVariablesUpdate']); + }); + + test('ctx.theme is applied to the guest document', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ctx-theme'); + await createCollection(page, 'apps-theme', collectionPath); + await createRequest(page, 'theme-req', 'apps-theme', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-theme', 'theme-req', { persist: true }); + + await setAppCode(page, CTX_APP); + await enableApp(page); + await waitForGuestReady(electronApp); + + const raw = await guestEval( + electronApp, + 'JSON.stringify({ theme: window.ctx.theme, bodyClass: document.body.className })' + ); + const { theme, bodyClass } = JSON.parse(raw as string); + expect(['light', 'dark']).toContain(theme); + expect(bodyClass).toContain(theme); + }); + + test('ctx.log is callable without throwing', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ctx-log'); + await createCollection(page, 'apps-log', collectionPath); + await createRequest(page, 'log-req', 'apps-log', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-log', 'log-req', { persist: true }); + + await setAppCode(page, CTX_APP); + await enableApp(page); + await waitForGuestReady(electronApp); + + const result = await guestEval(electronApp, 'window.__log()'); + expect(result).toBe('logged'); + }); + + test('ctx.sendRequest sends the request and resolves with the response', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ctx-send'); + await createCollection(page, 'apps-send', collectionPath); + await createRequest(page, 'send-req', 'apps-send', { method: 'POST', url: ECHO_JSON_URL }); + await openRequest(page, 'apps-send', 'send-req', { persist: true }); + + await setJsonBodyWithVar(page); + await setAppCode(page, CTX_APP); + await saveRequest(page); + await enableApp(page); + await waitForGuestReady(electronApp); + + await test.step('flat override keys become runtime variables', async () => { + await guestEval(electronApp, `void window.__send({ q: 'reflectme' })`); + await expect.poll(() => guestResult(electronApp), { timeout: 15000 }).toBe('reflectme'); + }); + + await test.step('explicit { variables } override is also honoured', async () => { + await guestEval(electronApp, `void window.__send({ variables: { q: 'viaExplicit' } })`); + await expect.poll(() => guestResult(electronApp), { timeout: 15000 }).toBe('viaExplicit'); + }); + }); + + test('ctx.setRuntimeVariable persists for subsequent sends', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ctx-setvar'); + await createCollection(page, 'apps-setvar', collectionPath); + await createRequest(page, 'setvar-req', 'apps-setvar', { method: 'POST', url: ECHO_JSON_URL }); + await openRequest(page, 'apps-setvar', 'setvar-req', { persist: true }); + + await setJsonBodyWithVar(page); + await setAppCode(page, CTX_APP); + await saveRequest(page); + await enableApp(page); + await waitForGuestReady(electronApp); + + await guestEval(electronApp, `window.__setVar('q', 'viaSet')`); + // Wait for the variable to round-trip back into the guest's ctx.variables + // (host dispatch → store update → AppView re-render → variables push) rather + // than guessing with a fixed timeout, then send with no override. + await expect + .poll(() => guestEval(electronApp, `window.ctx && window.ctx.variables && window.ctx.variables.q`), { timeout: 15000 }) + .toBe('viaSet'); + await guestEval(electronApp, 'void window.__send()'); + await expect.poll(() => guestResult(electronApp), { timeout: 15000 }).toBe('viaSet'); + }); + + test('the ctx bootstrap and user code are injected into the webview source', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ctx-bootstrap'); + await createCollection(page, 'apps-boot', collectionPath); + await createRequest(page, 'boot-req', 'apps-boot', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-boot', 'boot-req', { persist: true }); + + await setAppCode(page, CTX_APP); + await enableApp(page); + + const html = await getAppWebviewHtml(page); + // ctx API surface is present in the injected bootstrap + expect(html).toContain('window.ctx'); + expect(html).toContain('sendRequest'); + expect(html).toContain('setRuntimeVariable'); + expect(html).toContain('__brunoReceive'); + // user code is present + expect(html).toContain('window.__send'); + expect(html).toContain('window.__log'); + }); +}); diff --git a/tests/apps/apps-ui.spec.ts b/tests/apps/apps-ui.spec.ts new file mode 100644 index 000000000..2d475bd44 --- /dev/null +++ b/tests/apps/apps-ui.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '../../playwright'; +import { + createCollection, + createRequest, + openRequest, + selectRequestPaneTab, + setAppCode, + enableApp, + exitApp, + selectViewMode, + saveRequest, + closeAllTabs +} from '../utils/page'; + +const SIMPLE_APP = `
Hello from the app
`; + +// Read the app code currently loaded in the App-tab editor (via the CodeMirror API). +const readAppEditor = (page) => + page + .getByTestId('app-code-editor') + .locator('.CodeMirror') + .first() + .evaluate((el) => (el as any).CodeMirror?.getValue()); + +test.describe('Apps - request-level UI', () => { + test('App tab: enable takes over the panes, exit returns to the editor', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ui-toggle'); + await createCollection(page, 'apps-ui', collectionPath); + await createRequest(page, 'app-req', 'apps-ui', { + url: 'http://localhost:8081/api/echo/anything/x', + method: 'GET' + }); + await openRequest(page, 'apps-ui', 'app-req', { persist: true }); + + await test.step('App tab exposes the toggle and editor', async () => { + await selectRequestPaneTab(page, 'App'); + await expect(page.getByTestId('app-enable-toggle')).toBeVisible(); + await expect(page.getByTestId('app-code-editor')).toBeVisible(); + // request pane is still the normal request view while disabled + await expect(page.getByTestId('request-pane')).toBeVisible(); + }); + + await setAppCode(page, SIMPLE_APP); + + await test.step('Enabling app mode replaces the request/response area with the app view', async () => { + await enableApp(page); + await expect(page.getByTestId('app-view').locator('webview')).toBeVisible(); + await expect(page.getByTestId('request-pane')).toBeHidden(); + }); + + await test.step('Exit returns to the request pane / App editor', async () => { + await exitApp(page); + await selectRequestPaneTab(page, 'App'); + await expect(page.getByTestId('app-code-editor')).toBeVisible(); + }); + }); + + test('App tab shows a status-dot indicator once code is present', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ui-indicator'); + await createCollection(page, 'apps-ind', collectionPath); + await createRequest(page, 'ind-req', 'apps-ind', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-ind', 'ind-req', { persist: true }); + + await selectRequestPaneTab(page, 'App'); + // No code yet → no indicator + await expect(page.getByTestId('responsive-tab-app').getByTestId('status-dot-app')).toHaveCount(0); + + await setAppCode(page, SIMPLE_APP); + await expect(page.getByTestId('responsive-tab-app').getByTestId('status-dot-app')).toBeVisible(); + }); + + test('Collection toolbar view-mode toggle switches Request / App / File', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ui-viewmode'); + await createCollection(page, 'apps-mode', collectionPath); + await createRequest(page, 'mode-req', 'apps-mode', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-mode', 'mode-req', { persist: true }); + await setAppCode(page, SIMPLE_APP); + + await test.step('Request mode is active by default', async () => { + await expect(page.getByTestId('view-mode-request')).toHaveClass(/active/); + await expect(page.getByTestId('request-pane')).toBeVisible(); + }); + + await test.step('Switch to App mode', async () => { + await selectViewMode(page, 'app'); + await expect(page.getByTestId('view-mode-app')).toHaveClass(/active/); + await expect(page.getByTestId('app-view').locator('webview')).toBeVisible(); + }); + + await test.step('Switch to File mode (app view goes away)', async () => { + await selectViewMode(page, 'file'); + await expect(page.getByTestId('view-mode-file')).toHaveClass(/active/); + await expect(page.getByTestId('app-view')).toBeHidden(); + }); + + await test.step('Switch back to Request mode', async () => { + await selectViewMode(page, 'request'); + await expect(page.getByTestId('view-mode-request')).toHaveClass(/active/); + await expect(page.getByTestId('request-pane')).toBeVisible(); + }); + }); + + test('App code persists across save + reopen', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('apps-ui-persist'); + await createCollection(page, 'apps-persist', collectionPath); + await createRequest(page, 'persist-req', 'apps-persist', { url: 'http://localhost:8081/api/echo/anything/x' }); + await openRequest(page, 'apps-persist', 'persist-req', { persist: true }); + + await setAppCode(page, SIMPLE_APP); + await saveRequest(page); + + await closeAllTabs(page); + + await openRequest(page, 'apps-persist', 'persist-req', { persist: true }); + await selectRequestPaneTab(page, 'App'); + await expect(page.getByTestId('app-code-editor')).toBeVisible(); + await expect.poll(() => readAppEditor(page)).toBe(SIMPLE_APP); + // App mode starts disabled on reopen (enabled is runtime-only, not persisted) + await expect(page.getByTestId('app-enable-toggle')).toBeVisible(); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index d19f00172..e9f4ab858 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1944,6 +1944,75 @@ const generateCollectionDocs = async ( }); }; +/** + * Set the request's app code. Opens the App tab and writes the editor value + * directly via the CodeMirror API (avoids auto-close-bracket corruption when + * typing HTML/JS char-by-char). The app must not be enabled (editor visible). + * @param page - The page object + * @param code - The HTML/JS app code + */ +const setAppCode = async (page: Page, code: string) => { + await test.step('Set app code', async () => { + await selectRequestPaneTab(page, 'App'); + const editor = page.getByTestId('app-code-editor').locator('.CodeMirror').first(); + await editor.waitFor({ state: 'visible' }); + await editor.evaluate((el, val) => { + const cm = (el as any).CodeMirror; + if (cm) cm.setValue(val); + }, code); + }); +}; + +/** + * Enable app mode via the App tab's "Enable App" toggle. Asserts the app view + * takes over the request/response area. + * @param page - The page object + */ +const enableApp = async (page: Page) => { + await test.step('Enable app mode (App tab toggle)', async () => { + await selectRequestPaneTab(page, 'App'); + await page.getByTestId('app-enable-toggle').click(); + await expect(page.getByTestId('app-view')).toBeVisible({ timeout: 5000 }); + }); +}; + +/** + * Exit app mode via the app view's "Exit to editor" button. + * @param page - The page object + */ +const exitApp = async (page: Page) => { + await test.step('Exit app mode', async () => { + await page.getByTestId('app-exit-button').click(); + await expect(page.getByTestId('app-view')).toBeHidden({ timeout: 5000 }); + }); +}; + +/** + * Switch the active request's view mode using the collection toolbar toggle. + * @param page - The page object + * @param mode - 'request' | 'app' | 'file' + */ +const selectViewMode = async (page: Page, mode: 'request' | 'app' | 'file') => { + await test.step(`Switch view mode to "${mode}"`, async () => { + await page.getByTestId(`view-mode-${mode}`).click(); + }); +}; + +/** + * Read the decoded HTML the app webview is loading (its data: URL src). + * Useful for asserting the injected ctx bootstrap and user code. + * @param page - The page object + * @returns The decoded HTML document string + */ +const getAppWebviewHtml = async (page: Page): Promise => { + const webview = page.getByTestId('app-view').locator('webview'); + await webview.waitFor({ state: 'attached', timeout: 5000 }); + const src = await webview.getAttribute('src'); + if (!src) return ''; + const comma = src.indexOf(','); + return decodeURIComponent(src.slice(comma + 1)); +}; + /** * Rename a websocket message by double-clicking its label and typing a new name. * @param page - The page object @@ -2080,6 +2149,11 @@ export { openRequestInFolder, setUrlEncoding, generateCollectionDocs, + setAppCode, + enableApp, + exitApp, + selectViewMode, + getAppWebviewHtml, renameWsMessage };