mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 23:24:05 +00:00
feat(app): add request level app (#8294)
This commit is contained in:
52
packages/bruno-app/src/components/AppView/StyledWrapper.js
Normal file
52
packages/bruno-app/src/components/AppView/StyledWrapper.js
Normal 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;
|
||||
399
packages/bruno-app/src/components/AppView/index.js
Normal file
399
packages/bruno-app/src/components/AppView/index.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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] })),
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,6 +45,7 @@ const actionsToIntercept = [
|
||||
'collections/deleteVar',
|
||||
'collections/moveVar',
|
||||
'collections/updateRequestDocs',
|
||||
'collections/updateAppCode',
|
||||
'collections/runRequestEvent',
|
||||
'collections/updateCollectionPresets',
|
||||
'collections/setRequestVars',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
18
packages/bruno-filestore/src/formats/yml/common/app.ts
Normal file
18
packages/bruno-filestore/src/formats/yml/common/app.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
212
tests/apps/apps-ctx-api.spec.ts
Normal file
212
tests/apps/apps-ctx-api.spec.ts
Normal 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
121
tests/apps/apps-ui.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user