feat(app): add request level app (#8294)

This commit is contained in:
naman-bruno
2026-06-22 17:30:32 +05:30
committed by GitHub
parent 683d487181
commit d2de2d590e
22 changed files with 1181 additions and 5 deletions

View File

@@ -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;

View File

@@ -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 <webview>, 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(<json>)`)
* 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(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
// The ctx bridge. Injected as early as possible so window.ctx exists before user scripts run.
const BOOTSTRAP_SCRIPT = `<script>
(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' });
}
})();
</script>`;
const FRAGMENT_STYLES = `<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
background: #ffffff;
color: #1e1e1e;
transition: background-color 0.15s, color 0.15s;
}
body.dark { background: #1e1e1e; color: #e0e0e0; }
</style>`;
// 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 = /<html[\s>]/i.test(code) || /<!doctype/i.test(code);
if (isFullDocument) {
if (/<head[^>]*>/i.test(code)) {
return code.replace(/<head[^>]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`);
}
if (/<body[^>]*>/i.test(code)) {
return code.replace(/<body[^>]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`);
}
return `${BOOTSTRAP_SCRIPT}${code}`;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${FRAGMENT_STYLES}
${BOOTSTRAP_SCRIPT}
</head>
<body>
${code}
</body>
</html>`;
};
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 (
<StyledWrapper data-testid="app-view">
<div className="app-view-toolbar">
<span>App mode - {item.name}</span>
<button type="button" className="app-exit-btn" data-testid="app-exit-button" onClick={disableApp}>
Exit to editor
</button>
</div>
<div className="app-webview-container">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
</StyledWrapper>
);
};
export default AppView;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className="w-full h-full flex flex-col">
<div className="app-toggle-row mb-3 px-1 pb-3 flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium">Enable App</label>
<p className="text-xs opacity-70">
When enabled, replaces the request/response panes with the app view for this request.
</p>
</div>
<ToggleSwitch isOn={enabled} handleToggle={onToggle} size="m" data-testid="app-enable-toggle" />
</div>
<div className="flex-1 app-editor" data-testid="app-code-editor">
<CodeEditor
collection={collection}
value={code || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
onSave={onSave}
mode="javascript"
/>
</div>
</StyledWrapper>
);
};
export default AppCodeEditor;

View File

@@ -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 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
tests: tests?.length > 0 ? (hasTestError ? <StatusDot type="error" /> : <StatusDot />) : null,
docs: docs?.length > 0 ? <StatusDot /> : null,
app: app?.code?.length > 0 ? <StatusDot dataTestId="app" /> : null,
settings: tags?.length > 0 ? <StatusDot /> : 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] })),

View File

@@ -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 (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper className="flex flex-col flex-grow relative overflow-hidden">
<AppView item={item} collection={collection} code={appCode} />
</StyledWrapper>
</ScopedPersistenceProvider>
);
}
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;

View File

@@ -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;

View File

@@ -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 && (
<div className="flex flex-grow gap-1.5 items-center justify-end">
{isHttpRequestActive && (
<ToolHint text="Switch view mode" toolhintId="ViewModeToggleToolhintId" place="bottom">
<div className="mode-toggle" data-testid="view-mode-toggle">
<button
type="button"
data-testid="view-mode-request"
className={`mode-btn ${!appEnabled && !collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (collection.fileMode) handleFileModeClick();
if (appEnabled) handleToggleAppMode(false);
}}
title="Request"
>
<IconCode size={16} strokeWidth={1.5} />
</button>
<button
type="button"
data-testid="view-mode-app"
className={`mode-btn ${appEnabled && !collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (collection.fileMode) handleFileModeClick();
if (!appEnabled) handleToggleAppMode(true);
}}
title="App"
>
<IconApps size={16} strokeWidth={1.5} />
</button>
<button
type="button"
data-testid="view-mode-file"
className={`mode-btn ${collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (appEnabled) handleToggleAppMode(false);
if (!collection.fileMode) handleFileModeClick();
}}
title="File"
>
<IconFileCode size={16} strokeWidth={1.5} />
</button>
</div>
</ToolHint>
)}
{collection.format === 'bru' && !migratePillDismissed && (
<div
className="migrate-yml-pill"

View File

@@ -45,6 +45,7 @@ const actionsToIntercept = [
'collections/deleteVar',
'collections/moveVar',
'collections/updateRequestDocs',
'collections/updateAppCode',
'collections/runRequestEvent',
'collections/updateCollectionPresets',
'collections/setRequestVars',

View File

@@ -845,6 +845,9 @@ export const collectionsSlice = createSlice({
if (item.draft.settings) {
item.settings = item.draft.settings;
}
if (item.draft.app) {
item.app = item.draft.app;
}
item.draft = null;
}
}
@@ -2842,6 +2845,7 @@ export const collectionsSlice = createSlice({
request: file.data.request,
settings: file.data.settings,
examples: file.data.examples,
app: file.data.app,
filename: file.meta.name,
pathname: file.meta.pathname,
raw: file.data.raw,
@@ -2945,6 +2949,16 @@ export const collectionsSlice = createSlice({
item.request = mergeRequestWithPreservedUids(item.request, file.data.request);
item.settings = file.data.settings;
item.examples = file.data.examples;
// app.enabled is runtime-only and not persisted, so preserve it across file reloads
// even when the file no longer has an `app` block on disk.
const currentEnabled = item.draft?.app?.enabled ?? item.app?.enabled ?? false;
if (file.data.app) {
item.app = { ...file.data.app, enabled: currentEnabled };
} else if (currentEnabled) {
item.app = { code: null, enabled: true };
} else {
item.app = null;
}
item.filename = file.meta.name;
item.pathname = file.meta.pathname;
item.raw = file.data.raw;
@@ -3371,6 +3385,39 @@ export const collectionsSlice = createSlice({
}
}
},
updateAppCode: (state, action) => {
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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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
};
};

View File

@@ -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: [],

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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()
})

View File

@@ -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 <webview> 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 = `
<div id="out" data-result="pending">pending</div>
<script>
window.__send = function (overrides) {
document.getElementById('out').setAttribute('data-result', 'sending');
return ctx.sendRequest(overrides)
.then(function (res) {
var d = res && res.data;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) {} }
var q = (d && d.q) ? d.q : '(none)';
document.getElementById('out').setAttribute('data-result', String(q));
})
.catch(function (e) {
document.getElementById('out').setAttribute('data-result', 'ERR:' + (e && e.message));
});
};
window.__setVar = function (k, v) { ctx.setRuntimeVariable(k, v); };
window.__log = function () { ctx.log('hello from app', 42); return 'logged'; };
</script>`;
// 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');
});
});

121
tests/apps/apps-ui.spec.ts Normal file
View File

@@ -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 = `<div id="hello">Hello from the app</div>`;
// 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();
});
});

View File

@@ -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<string> => {
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
};