feat(app): introduce standalone app functionality (#8338)

This commit is contained in:
naman-bruno
2026-06-24 13:21:05 +05:30
committed by GitHub
parent cbc812b131
commit 84c53b65c2
28 changed files with 1563 additions and 168 deletions

View File

@@ -27,6 +27,18 @@ const SUGGESTIONS = {
{ label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' },
{ label: 'Examples', prompt: 'Add request and response examples with sample JSON' },
{ label: 'Errors', prompt: 'Document common error responses and status codes' }
],
'app-request': [
{ label: 'Send button', prompt: 'Add a button that calls ctx.sendRequest() and displays the response status, headers, and pretty-printed body' },
{ label: 'Form for body', prompt: 'Build a form whose fields override the request body, then send it with ctx.sendRequest({ variables }) and show the result' },
{ label: 'Response viewer', prompt: 'Render ctx.response with collapsible JSON and a banner showing status and response time; update on ctx.onResponseUpdate' },
{ label: 'Test results', prompt: 'List ctx.testResults and ctx.assertionResults with pass/fail badges; refresh on ctx.onResultsUpdate' }
],
'app-collection': [
{ label: 'Request list', prompt: 'List all requests from ctx.listRequests() with their method and url, and a Run button next to each that calls ctx.runRequest(pathname)' },
{ label: 'Dashboard', prompt: 'Build a small dashboard that runs every request from ctx.listRequests() on load and shows status code, response time, and a pass/fail dot for each' },
{ label: 'Form runner', prompt: 'Render a form, and on submit call ctx.runRequest(pathname, { variables }) for a chosen request and display the response' },
{ label: 'Variables panel', prompt: 'Show ctx.variables in a table and allow editing values via ctx.setRuntimeVariable(key, value); react to ctx.onVariablesUpdate' }
]
};
@@ -34,11 +46,15 @@ const TITLES = {
'tests': 'Generate Tests',
'pre-request': 'Generate Pre-Request Script',
'post-response': 'Generate Post-Response Script',
'docs': 'Generate Documentation'
'docs': 'Generate Documentation',
'app-request': 'Generate App',
'app-collection': 'Generate App'
};
const PREVIEW_LABELS = {
docs: 'Preview · replaces current documentation'
'docs': 'Preview · replaces current documentation',
'app-request': 'Preview · replaces current app',
'app-collection': 'Preview · replaces current app'
};
const isValidType = (t) => SUGGESTIONS[t] !== undefined;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import styled from 'styled-components';
import { IconApps } from '@tabler/icons';
const Wrapper = styled.div`
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.muted};
.empty-app-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
max-width: 360px;
}
.empty-app-title {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.empty-app-hint {
font-size: 12px;
line-height: 1.4;
}
`;
const EmptyAppState = ({ title = 'No app yet', hint }) => (
<Wrapper data-testid="empty-app-state">
<div className="empty-app-inner">
<IconApps size={32} strokeWidth={1.25} />
<div className="empty-app-title">{title}</div>
{hint ? <div className="empty-app-hint">{hint}</div> : null}
</div>
</Wrapper>
);
export default EmptyAppState;

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react';
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch } from 'react-redux';
import { sendNetworkRequest } from 'utils/network/index';
@@ -7,31 +7,28 @@ import {
getEnvironmentVariables,
getGlobalEnvironmentVariables
} from 'utils/collections';
import { responseReceived, appSetRuntimeVariable, toggleAppMode, initRunRequestEvent } from 'providers/ReduxStore/slices/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';
import EmptyAppState from './EmptyAppState';
import {
SENTINEL,
wrapHtml,
toDataUrl,
serializeTimeline,
projectResponse,
useAppWebview
} from './webview-bridge';
/*
* 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>
// Request-level ctx bootstrap. Injected into the guest so window.ctx exists
// before user scripts run.
const REQUEST_CTX_BOOTSTRAP = `<script>
(function () {
if (window.__brunoBootstrapped) return;
window.__brunoBootstrapped = true;
@@ -81,7 +78,6 @@ const BOOTSTRAP_SCRIPT = `<script>
}
}
// Host -> guest entry point.
window.__brunoReceive = function (msg) {
if (!msg) return;
switch (msg.type) {
@@ -130,66 +126,6 @@ const BOOTSTRAP_SCRIPT = `<script>
})();
</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({
@@ -207,13 +143,7 @@ const buildVariables = (collection) => {
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 src = useMemo(() => toDataUrl(wrapHtml(REQUEST_CTX_BOOTSTRAP, code || '')), [code]);
const environment = useMemo(
() => findEnvironmentInCollection(collection, collection.activeEnvironmentUid),
@@ -224,28 +154,26 @@ const AppView = ({ item, collection, code }) => {
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]);
// pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
// routing through a ref lets the callbacks call the *latest* pushToGuest without
// creating a circular useCallback dependency. Without this, the request-id reply
// (and error reply) close over the first-render no-op pushToGuest and the guest's
// ctx.sendRequest() promise never resolves.
const pushToGuestRef = useRef(() => {});
const handleSendRequest = useCallback(
async (requestId, overrides) => {
const push = pushToGuestRef.current;
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).
// test/assertion/script events against an id the store recognises — this
// is what makes ctx.testResults / ctx.assertionResults populate.
const requestUid = uuid();
const requestItem = cloneDeep(item.draft || item);
requestItem.requestUid = requestUid;
dispatch(initRunRequestEvent({ requestUid, itemUid: item.uid, collectionUid: collection.uid }));
// Variable overrides: accept flat keys or { variables: {...} }.
const flatOverrides = overrides && typeof overrides === 'object' ? { ...overrides } : {};
const explicitVars = flatOverrides.variables;
delete flatOverrides.variables;
@@ -257,13 +185,13 @@ const AppView = ({ item, collection, code }) => {
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.
// sendNetworkRequest resolves on network/request errors with `error` set —
// surface as a guest-side promise rejection rather than a fake success.
if (result?.error) {
const errorMessage = typeof result.error === 'string'
? result.error
: result.error?.message || 'Request failed';
pushToGuest({ type: 'response', requestId, error: errorMessage });
push({ type: 'response', requestId, error: errorMessage });
return;
}
@@ -284,19 +212,18 @@ const AppView = ({ item, collection, code }) => {
})
);
pushToGuest({ type: 'response', requestId, response: projectResponse(result) });
push({ type: 'response', requestId, response: projectResponse(result) });
} catch (err) {
pushToGuest({ type: 'response', requestId, error: err?.message || 'Request failed' });
push({ type: 'response', requestId, error: err?.message || 'Request failed' });
}
},
[item, collection, environment, dispatch, pushToGuest]
[item, collection, environment, dispatch]
);
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);
@@ -316,38 +243,12 @@ const AppView = ({ item, collection, code }) => {
[handleSendRequest, dispatch, collection.uid]
);
useEffect(() => {
const webview = webviewRef.current;
if (!webview) return;
const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
pushToGuestRef.current = pushToGuest;
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.
// Push a full state snapshot on each readiness transition. Subsequent changes
// are handled by the granular effects below; using a ref avoids re-firing
// this effect (which would be a needless full re-broadcast).
const stateRef = useRef();
stateRef.current = { theme: displayedTheme, response, assertionResults, testResults, variables };
useEffect(() => {
@@ -383,15 +284,22 @@ const AppView = ({ item, collection, code }) => {
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"
{code && code.trim().length ? (
<div className="app-webview-container">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
) : (
<EmptyAppState
title="No app yet"
hint="Switch to the App tab on this request and write some HTML/JS to get started."
/>
</div>
)}
</StyledWrapper>
);
};

View File

@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/*
* Shared transport for Bruno apps that run inside an Electron <webview>:
* host -> guest : webview.executeJavaScript(`window.__brunoReceive(<json>)`)
* guest -> host : console.log(SENTINEL + json), surfaced via 'console-message'
*
* Both the request-level AppView and the standalone CollectionApp use this — they
* differ only in the bootstrap script (which builds window.ctx) and the message
* handler the host registers.
*/
export const SENTINEL = '__BRUNO_APP_MSG__';
// JSON-encode for safe inlining into an executeJavaScript() string literal.
// U+2028/U+2029 are legal in JSON strings but illegal as raw JS source.
export const toJsArg = (value) =>
JSON.stringify(value === undefined ? null : value)
.replace(/</g, '\\u003c')
.replace(/[\u2028]/g, '\\u2028')
.replace(/[\u2029]/g, '\\u2029');
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>`;
/**
* Wrap user code into a guest document, injecting the host-supplied bootstrap
* script as early as possible (right after <head>) so window.ctx exists before
* any user script runs. Full HTML documents have the bootstrap injected; bare
* fragments are placed inside a minimal shell.
*/
export const wrapHtml = (bootstrap, 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}`);
}
if (/<body[^>]*>/i.test(code)) {
return code.replace(/<body[^>]*>/i, (m) => `${m}${bootstrap}`);
}
return `${bootstrap}${code}`;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${FRAGMENT_STYLES}
${bootstrap}
</head>
<body>
${code}
</body>
</html>`;
};
export const toDataUrl = (html) =>
`data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
export const serializeTimeline = (timeline) => {
if (!Array.isArray(timeline)) return timeline;
return timeline.map((entry) => ({
...entry,
timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
}));
};
export 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
});
/**
* useAppWebview — manages an Electron <webview> guest and provides a typed
* messaging channel back to the host.
*
* const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
* …
* <webview ref={webviewRef} src={…} … />
*
* `webviewRef` is a **callback ref** (not an object ref). React invokes it with
* the element on mount and with `null` on unmount, which is the only way to
* reliably re-attach listeners when the <webview> is unmounted and remounted —
* e.g. when CollectionApp's user toggles between Code and Preview views. An
* object-ref + useEffect approach would not re-fire on remount because the ref
* object's identity is stable across mounts.
*
* pushToGuest({…}) is a no-op until the guest's dom-ready fires (and after a
* reload, until it fires again). Safe to call eagerly from effects.
*/
export const useAppWebview = (onGuestMessage) => {
const [domReady, setDomReady] = useState(false);
// Latest DOM element (for pushToGuest) and latest message handler (so the
// listener captures fresh state without needing to be re-bound).
const webviewElRef = useRef(null);
const onGuestMessageRef = useRef(onGuestMessage);
onGuestMessageRef.current = onGuestMessage;
// Outgoing messages sent before the guest is ready are queued and flushed by
// the dom-ready effect below. This is critical for guest scripts that call
// promise-returning ctx APIs (e.g. ctx.listRequests) at parse time — the host
// receives the request via console-message before Electron's `dom-ready`
// fires, and without a queue the reply gets dropped and the promise never
// resolves.
const pendingOutbox = useRef([]);
const sendToWebview = (webview, msg) => {
try {
webview.executeJavaScript(
`window.__brunoReceive && window.__brunoReceive(${toJsArg(msg)})`
).catch(() => {});
} catch (_) {
/* webview not yet attached */
}
};
const pushToGuest = useCallback(
(msg) => {
const webview = webviewElRef.current;
if (!webview || !domReady) {
pendingOutbox.current.push(msg);
return;
}
sendToWebview(webview, msg);
},
[domReady]
);
// Flush whatever piled up while the guest was still loading.
useEffect(() => {
if (!domReady) return;
const webview = webviewElRef.current;
if (!webview) return;
const queue = pendingOutbox.current;
if (!queue.length) return;
pendingOutbox.current = [];
for (const msg of queue) sendToWebview(webview, msg);
}, [domReady]);
// Stable callback ref. We stash the per-element listener bag on the element
// itself so we can clean up exactly the right listeners on unmount or replace.
const webviewRef = useCallback((element) => {
const prev = webviewElRef.current;
if (prev && prev !== element) {
const h = prev.__brunoHandlers;
if (h) {
prev.removeEventListener('console-message', h.onConsoleMessage);
prev.removeEventListener('dom-ready', h.onDomReady);
prev.removeEventListener('did-start-loading', h.onStartLoading);
prev.__brunoHandlers = null;
}
}
// Queued messages belong to the prior guest; drop them on element replace.
pendingOutbox.current = [];
webviewElRef.current = element || null;
// dom-ready will fire fresh on the new element; until then pushToGuest no-ops.
setDomReady(false);
if (!element) return;
const onConsoleMessage = (e) => {
const text = e?.message;
if (typeof text !== 'string' || !text.startsWith(SENTINEL)) return;
try {
onGuestMessageRef.current(JSON.parse(text.slice(SENTINEL.length)));
} catch (_) {
/* not our message */
}
};
const onDomReady = () => setDomReady(true);
// A reload (code edit) tears down the guest; reset readiness so the next
// dom-ready can flip us back to true.
const onStartLoading = () => setDomReady(false);
element.__brunoHandlers = { onConsoleMessage, onDomReady, onStartLoading };
element.addEventListener('console-message', onConsoleMessage);
element.addEventListener('dom-ready', onDomReady);
element.addEventListener('did-start-loading', onStartLoading);
}, []);
return { domReady, pushToGuest, webviewRef };
};

View File

@@ -0,0 +1,84 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0.5rem;
.app-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.5rem;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.app-toolbar .view-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;
}
.app-toolbar .view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
height: 100%;
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;
font-size: 11px;
&: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};
}
}
.app-pane {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.app-pane.code div.CodeMirror {
height: 100%;
}
.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,394 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { sendNetworkRequest } from 'utils/network/index';
import {
findEnvironmentInCollection,
findItemInCollectionByPathname,
flattenItems,
getEnvironmentVariables,
getGlobalEnvironmentVariables,
isItemARequest
} from 'utils/collections';
import { uuid } from 'utils/common';
import {
appSetRuntimeVariable,
initRunRequestEvent,
responseReceived,
updateAppCode
} from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildDocsContextFromCollection } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import EmptyAppState from '../AppView/EmptyAppState';
import {
SENTINEL,
wrapHtml,
toDataUrl,
serializeTimeline,
projectResponse,
useAppWebview
} from '../AppView/webview-bridge';
/*
* Standalone collection-/folder-level app — a file (.bru/.yml) of type 'app'
* that lives in the sidebar and opens as its own tab. The user toggles between
* Code (CodeEditor) and Preview (sandboxed <webview>); preview re-runs whenever
* the code prop changes.
*
* Collection ctx surface differs from the request-level AppView:
* shared: theme, log, variables, setRuntimeVariable, onThemeChange, onVariablesUpdate
* added: collection, listRequests(), runRequest(pathname, overrides?)
* dropped: sendRequest, response, assertionResults, testResults
* (and their on* hooks — they only make sense for one request)
*/
const COLLECTION_CTX_BOOTSTRAP = `<script>
(function () {
if (window.__brunoBootstrapped) return;
window.__brunoBootstrapped = true;
var SENTINEL = ${JSON.stringify(SENTINEL)};
var pending = new Map();
var nextReplyId = 0;
function sendToHost(payload) {
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
}
function awaitReply(type, extra) {
return new Promise(function (resolve, reject) {
var replyId = ++nextReplyId;
pending.set(replyId, { resolve: resolve, reject: reject });
sendToHost(Object.assign({ type: type, replyId: replyId }, extra || {}));
});
}
var ctx = {
theme: 'light',
variables: {},
collection: null,
onThemeChange: null,
onVariablesUpdate: null,
listRequests: function () {
return awaitReply('listRequests');
},
runRequest: function (pathname, overrides) {
return awaitReply('runRequest', { pathname: String(pathname || ''), 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);
}
}
window.__brunoReceive = function (msg) {
if (!msg) return;
switch (msg.type) {
case 'state':
applyTheme(msg.theme);
ctx.variables = msg.variables || {};
ctx.collection = msg.collection || null;
break;
case 'theme':
applyTheme(msg.theme);
if (typeof ctx.onThemeChange === 'function') ctx.onThemeChange(ctx.theme);
break;
case 'variables':
ctx.variables = msg.variables || {};
if (typeof ctx.onVariablesUpdate === 'function') ctx.onVariablesUpdate(ctx.variables);
break;
case 'collection':
ctx.collection = msg.collection || null;
break;
case 'reply': {
var entry = pending.get(msg.replyId);
if (!entry) return;
pending.delete(msg.replyId);
if (msg.error) entry.reject(new Error(msg.error));
else entry.resolve(msg.result);
break;
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { sendToHost({ type: 'ready' }); });
} else {
sendToHost({ type: 'ready' });
}
})();
</script>`;
const buildVariables = (collection) => {
const env = getEnvironmentVariables(collection);
const global = getGlobalEnvironmentVariables({
globalEnvironments: collection?.globalEnvironments || [],
activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid
});
return {
...global,
...env,
...(collection?.collectionVariables || {}),
...(collection?.runtimeVariables || {})
};
};
const listRequestSummaries = (collection) =>
flattenItems(collection?.items || [])
.filter(isItemARequest)
.map((it) => ({
uid: it.uid,
name: it.name,
pathname: it.pathname,
type: it.type,
method: it.request?.method || null,
url: it.request?.url || null
}));
const CollectionApp = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [view, setView] = useState('preview');
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
// Preview HTML is keyed on the *saved* code so typing doesn't reload the guest
// on every keystroke. The user toggles to Preview after saving to see updates.
const src = useMemo(
() => toDataUrl(wrapHtml(COLLECTION_CTX_BOOTSTRAP, code || '')),
[code]
);
const environment = useMemo(
() => findEnvironmentInCollection(collection, collection.activeEnvironmentUid),
[collection]
);
const variables = useMemo(() => buildVariables(collection), [collection]);
const collectionInfo = useMemo(
() => ({ name: collection?.name || null, pathname: collection?.pathname || null }),
[collection?.name, collection?.pathname]
);
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
const onEdit = useCallback(
(value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })),
[dispatch, item.uid, collection.uid]
);
const onSave = useCallback(
() => dispatch(saveRequest(item.uid, collection.uid)),
[dispatch, item.uid, collection.uid]
);
// Execute a single request by its pathname (returned earlier from listRequests).
// Mirrors AppView.handleSendRequest: mints a requestUid, registers the run, merges
// overrides into runtime variables, sends, and dispatches responseReceived so the
// request's normal Response pane updates too.
const runRequestByPath = useCallback(
async (pathname, overrides) => {
const target = findItemInCollectionByPathname(collection, pathname);
if (!target) {
throw new Error(`Request not found: ${pathname}`);
}
if (!isItemARequest(target)) {
throw new Error(`Item is not a request: ${pathname}`);
}
const requestUid = uuid();
const requestItem = cloneDeep(target.draft || target);
requestItem.requestUid = requestUid;
dispatch(
initRunRequestEvent({ requestUid, itemUid: target.uid, collectionUid: collection.uid })
);
const flat = overrides && typeof overrides === 'object' ? { ...overrides } : {};
const explicit = flat.variables;
delete flat.variables;
const mergedRuntime = {
...(collection.runtimeVariables || {}),
...flat,
...(explicit && typeof explicit === 'object' ? explicit : {})
};
const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime);
if (result?.error) {
const errorMessage = typeof result.error === 'string'
? result.error
: result.error?.message || 'Request failed';
throw new Error(errorMessage);
}
dispatch(
responseReceived({
itemUid: target.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)
}
})
);
return projectResponse(result);
},
[collection, environment, dispatch]
);
// pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
// so we can't put it in handleGuestMessage's useCallback deps (circular). Instead
// route guest replies through a ref that always points at the latest pushToGuest.
// Without this, the callback closes over the first-render pushToGuest (which is a
// no-op until dom-ready) and reply messages never reach the guest.
const pushToGuestRef = useRef(() => {});
const handleGuestMessage = useCallback(
async (data) => {
const push = pushToGuestRef.current;
switch (data?.type) {
case 'ready':
break;
case 'log':
console.log('[app]', ...(data.args || []));
break;
case 'setRuntimeVariable':
if (typeof data.key === 'string' && data.key.length) {
dispatch(
appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value })
);
}
break;
case 'listRequests': {
push({ type: 'reply', replyId: data.replyId, result: listRequestSummaries(collection) });
break;
}
case 'runRequest': {
try {
const res = await runRequestByPath(data.pathname, data.overrides);
push({ type: 'reply', replyId: data.replyId, result: res });
} catch (err) {
push({ type: 'reply', replyId: data.replyId, error: err?.message || 'runRequest failed' });
}
break;
}
default:
break;
}
},
[dispatch, collection, runRequestByPath]
);
const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
pushToGuestRef.current = pushToGuest;
const stateRef = useRef();
stateRef.current = { theme: displayedTheme, variables, collection: collectionInfo };
useEffect(() => {
if (!domReady) return;
pushToGuest({ type: 'state', ...stateRef.current });
}, [domReady, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'theme', theme: displayedTheme });
}, [displayedTheme, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'variables', variables });
}, [variables, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'collection', collection: collectionInfo });
}, [collectionInfo, pushToGuest]);
return (
<StyledWrapper data-testid="collection-app">
<div className="app-toolbar">
<span>App - {item.name}</span>
<div className="view-toggle" data-testid="collection-app-view-toggle">
<button
type="button"
data-testid="collection-app-view-code"
className={classnames('view-btn', { active: view === 'code' })}
onClick={() => setView('code')}
>
Code
</button>
<button
type="button"
data-testid="collection-app-view-preview"
className={classnames('view-btn', { active: view === 'preview' })}
onClick={() => setView('preview')}
>
Preview
</button>
</div>
</div>
{view === 'code' ? (
<div className="app-pane code relative" data-testid="collection-app-code">
<CodeEditor
collection={collection}
value={code || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
onSave={onSave}
mode="htmlmixed"
/>
<AIAssist
scriptType="app-collection"
currentScript={code || ''}
docsContext={docsContext}
onApply={onEdit}
/>
</div>
) : code && code.trim().length ? (
<div className="app-pane app-webview-container" data-testid="collection-app-preview">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
) : (
<div className="app-pane" data-testid="collection-app-preview">
<EmptyAppState
title="No app yet"
hint="Switch to Code and write some HTML/JS"
/>
</div>
)}
</StyledWrapper>
);
};
export default CollectionApp;

View File

@@ -1,8 +1,10 @@
import React from 'react';
import React, { useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import ToggleSwitch from 'components/ToggleSwitch';
import AIAssist from 'components/AIAssist';
import { buildRequestContextFromItem } from 'utils/ai';
import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -24,6 +26,8 @@ const AppCodeEditor = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
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">
@@ -36,7 +40,7 @@ const AppCodeEditor = ({ item, collection }) => {
<ToggleSwitch isOn={enabled} handleToggle={onToggle} size="m" data-testid="app-enable-toggle" />
</div>
<div className="flex-1 app-editor" data-testid="app-code-editor">
<div className="flex-1 app-editor relative" data-testid="app-code-editor">
<CodeEditor
collection={collection}
value={code || ''}
@@ -47,6 +51,12 @@ const AppCodeEditor = ({ item, collection }) => {
onSave={onSave}
mode="javascript"
/>
<AIAssist
scriptType="app-request"
currentScript={code || ''}
requestContext={requestContext}
onApply={onEdit}
/>
</div>
</StyledWrapper>
);

View File

@@ -22,6 +22,7 @@ import { DocExplorer } from '@usebruno/graphql-docs';
import FileEditor from 'components/FileEditor';
import AppView from 'components/AppView';
import CollectionApp from 'components/CollectionApp';
import StyledWrapper from './StyledWrapper';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
@@ -495,6 +496,18 @@ const RequestTabPanel = () => {
);
}
// Standalone app item (collection- or folder-level). Renders as its own tab
// with a Code/Preview toggle and its own ctx API surface.
if (item.type === 'app') {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper className="flex flex-col flex-grow relative overflow-hidden">
<CollectionApp item={item} collection={collection} />
</StyledWrapper>
</ScopedPersistenceProvider>
);
}
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', '');

View File

@@ -16,6 +16,7 @@ import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnviron
import RequestTabNotFound from './RequestTabNotFound';
import RequestTabLoading from './RequestTabLoading';
import SpecialTab from './SpecialTab';
import { IconApps } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
@@ -576,9 +577,15 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
{method}
</span>
{item.type === 'app' ? (
<span className="tab-method flex items-center" aria-label="App">
<IconApps size={14} strokeWidth={1.5} />
</span>
) : (
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
{method}
</span>
)}
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
{item.name}
</span>

View File

@@ -1,5 +1,5 @@
import RequestMethod from '../RequestMethod';
import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons';
import { IconLoader2, IconAlertTriangle, IconAlertCircle, IconApps } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollectionItemIcon = ({ item }) => {
@@ -15,6 +15,10 @@ const CollectionItemIcon = ({ item }) => {
return <StyledWrapper><IconAlertTriangle size={18} className="w-fit mr-2 partial" strokeWidth={1.5} /></StyledWrapper>;
}
if (item?.type === 'app') {
return <IconApps className="w-fit mr-2" size={16} strokeWidth={1.5} />;
}
return <RequestMethod item={item} />;
};

View File

@@ -18,7 +18,8 @@ import {
IconTrash,
IconSettings,
IconInfoCircle,
IconTerminal2
IconTerminal2,
IconApps
} from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
@@ -29,6 +30,7 @@ import { uuid } from 'utils/common';
import { copyRequest, setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import NewApp from 'components/Sidebar/NewApp';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
@@ -95,6 +97,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [newAppModalOpen, setNewAppModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const [examplesExpanded, setExamplesExpanded] = useState(false);
@@ -257,7 +260,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
// scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
if (isRequest) {
const isApp = item.type === 'app';
if (isRequest || isApp) {
if (isTabForItemPresent) {
dispatch(
focusTab({
@@ -270,7 +274,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
addTab({
uid: item.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item),
...(isRequest ? { requestPaneTab: getDefaultRequestPaneTab(item) } : {}),
type: item.type,
pathname: item.pathname
})
@@ -351,6 +355,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
label: 'New Folder',
onClick: () => setNewFolderModalOpen(true)
},
{
id: 'new-app',
leftSection: IconApps,
label: 'New App',
onClick: () => setNewAppModalOpen(true)
},
{
id: 'run',
leftSection: IconPlayerPlay,
@@ -547,7 +557,10 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
};
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
// Standalone 'app' items live alongside requests in the folder listing.
const requestItems = sortItemsBySequence(
filter(item.items, (i) => (isItemARequest(i) || i.type === 'app') && !i.isTransient)
);
const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length;
const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid });
@@ -631,6 +644,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
{newFolderModalOpen && (
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
)}
{newAppModalOpen && (
<NewApp item={item} collectionUid={collectionUid} onClose={() => setNewAppModalOpen(false)} />
)}
{runCollectionModalOpen && (
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}

View File

@@ -21,7 +21,8 @@ import {
IconTerminal2,
IconFolder,
IconBook,
IconFileArrowRight
IconFileArrowRight,
IconApps
} from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
@@ -32,6 +33,7 @@ import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import NewApp from 'components/Sidebar/NewApp';
import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import MoveToWorkspace from './MoveToWorkspace';
@@ -64,6 +66,7 @@ const Collection = ({ collection, searchText }) => {
const { dropdownContainerRef } = useSidebarAccordion();
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showNewAppModal, setShowNewAppModal] = useState(false);
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
@@ -78,7 +81,7 @@ const Collection = ({ collection, searchText }) => {
const collectionRef = useRef(null);
// Only count persisted requests and folders; transients and file items
// (bruno.json, .js scripts) don't affect empty state
const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i))).length || 0;
const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0;
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
@@ -334,7 +337,11 @@ const Collection = ({ collection, searchText }) => {
return items.sort((a, b) => a.seq - b.seq);
};
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
// Standalone 'app' items sit alongside requests in the listing — both are
// file leaves that share the seq-based ordering.
const requestItems = sortItemsBySequence(
filter(collection.items, (i) => (isItemARequest(i) || i.type === 'app') && !i.isTransient)
);
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
const showEmptyCollectionMessage = showEmptyState && !hasSearchText;
@@ -359,6 +366,15 @@ const Collection = ({ collection, searchText }) => {
setShowNewFolderModal(true);
}
},
{
id: 'new-app',
leftSection: IconApps,
label: 'New App',
onClick: () => {
ensureCollectionIsMounted();
setShowNewAppModal(true);
}
},
{
id: 'run',
leftSection: IconPlayerPlay,
@@ -477,6 +493,7 @@ const Collection = ({ collection, searchText }) => {
<StyledWrapper className="flex flex-col" id={`collection-${collection.name.replace(/\s+/g, '-').toLowerCase()}`}>
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
{showNewAppModal && <NewApp collectionUid={collection.uid} onClose={() => setShowNewAppModal(false)} />}
{showRenameCollectionModal && (
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
)}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import Modal from 'components/Modal';
import { newApp } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
const NewApp = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector((state) =>
state.collections.collections?.find((c) => c.uid === collectionUid)
);
const formik = useFormik({
enableReinitialize: true,
initialValues: { appName: '' },
validationSchema: Yup.object({
appName: Yup.string()
.trim()
.min(1, 'App name is required')
.max(255, 'Must be 255 characters or less')
.test('valid-name', validateNameError, (value) => validateName(value || ''))
.required('App name is required')
}),
onSubmit: (values) => {
const name = values.appName.trim();
dispatch(
newApp({
appName: name,
filename: sanitizeName(name),
collectionUid,
itemUid: item ? item.uid : null
})
)
.then(() => {
toast.success('App created');
onClose();
})
.catch((err) => toast.error(err?.message || 'Failed to create app'));
}
});
const onSubmit = () => formik.handleSubmit();
return (
<Modal
size="sm"
title="New App"
confirmText="Create"
handleConfirm={onSubmit}
handleCancel={onClose}
disableEscapeKey={false}
disableCloseOnOutsideClick={false}
>
<form className="bruno-form" onSubmit={formik.handleSubmit} data-testid="new-app-form">
<label htmlFor="appName" className="block font-semibold">
Name
</label>
<input
id="appName"
type="text"
name="appName"
data-testid="new-app-name-input"
autoFocus
autoComplete="off"
spellCheck="false"
className="block textbox mt-2 w-full"
value={formik.values.appName}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.appName && formik.errors.appName ? (
<div className="text-red-500 text-xs mt-1">{formik.errors.appName}</div>
) : (
<div className="text-xs mt-2 opacity-70">
Creates a standalone app file in {item ? 'this folder' : `collection "${collection?.name || ''}"`}.
</div>
)}
</form>
</Modal>
);
};
export default NewApp;

View File

@@ -43,6 +43,7 @@ const actionsToIntercept = [
'collections/deleteVar',
'collections/moveVar',
'collections/updateRequestDocs',
'collections/updateAppCode',
'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink.
// Folder-level actions

View File

@@ -1767,6 +1767,101 @@ export const newWsRequest = (params) => (dispatch, getState) => {
});
};
const DEFAULT_APP_STARTER = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>App</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 1rem; }
button { padding: 6px 10px; cursor: pointer; }
pre { background: #f6f7f9; padding: 8px; border-radius: 4px; overflow: auto; }
body.dark { color: #e0e0e0; }
body.dark pre { background: #1f2123; }
</style>
</head>
<body>
<h2>Hello from a Bruno app</h2>
<p>This app can list request in the collection.</p>
<button id="refresh">List requests</button>
<pre id="out">click "List requests"</pre>
<script>
const out = document.getElementById('out');
document.getElementById('refresh').addEventListener('click', async () => {
const requests = await ctx.listRequests();
out.textContent = requests.map(r => \`\${r.method || r.type} \${r.name}\`).join('\\n') || '(no requests)';
});
</script>
</body>
</html>
`;
export const newApp = (params) => (dispatch, getState) => {
const { appName, filename, collectionUid, itemUid } = params;
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
const item = {
uid: uuid(),
type: 'app',
name: appName,
filename,
app: { code: DEFAULT_APP_STARTER },
settings: {}
};
const resolvedFilename = resolveRequestFilename(filename, collection.format);
const selectedItem = itemUid ? findItemInCollection(collection, itemUid) : null;
let parent = collection;
if (selectedItem) {
parent = isItemAFolder(selectedItem)
? selectedItem
: (findParentItemInCollection(collection, selectedItem.uid) || collection);
}
const parentPath = parent.pathname;
const siblings = parent.items || [];
const dupe = find(
siblings,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
if (dupe) {
return reject(new Error('An item with this name already exists in this folder'));
}
const orderableSiblings = filter(
siblings,
(i) => isItemAFolder(i) || isItemARequest(i) || i.type === 'app'
);
item.seq = orderableSiblings.length + 1;
const fullName = path.join(parentPath, resolvedFilename);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-request', fullName, item)
.then(() => {
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
})
);
resolve();
})
.catch(reject);
});
};
export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);

View File

@@ -3390,7 +3390,8 @@ export const collectionsSlice = createSlice({
if (!collection) return;
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
// Accept both request-attached apps and standalone 'app' items.
if (item && (isItemARequest(item) || item.type === 'app')) {
if (!item.draft) {
item.draft = cloneDeep(item);
}

View File

@@ -691,6 +691,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
export const transformRequestToSaveToFilesystem = (item) => {
const _item = item.draft ? item.draft : item;
// Standalone app items have no request, emit only what the filestore needs.
if (_item.type === 'app') {
return {
uid: _item.uid,
type: 'app',
name: _item.name,
seq: _item.seq,
tags: _item.tags,
settings: _item.settings,
app: { code: _item.app?.code || '' }
};
}
// Transform examples to ensure status is a number
const transformExamples = (examples = []) => {
return map(examples, (example) => ({

View File

@@ -142,6 +142,85 @@ Do NOT use \`test()\` or \`expect()\` — those belong in the Tests tab.
${COMMON_OUTPUT_RULES}`,
'app-request': `You are an AI assistant that writes Bruno App code attached to an HTTP request.
## App Context
A Bruno App is a self-contained UI that runs inside a sandboxed <webview>. The user's code is injected into the body of a generated HTML document at runtime — it must be fully independent. Plain HTML, CSS, and JavaScript only. No bundler, no module imports, no JSX, no React import statements (React is allowed only if loaded inline via <script> tags + Babel from CDN). Output can be a bare HTML fragment or a full \`<html>\` document.
Before any user script runs, a global \`window.ctx\` is provided by the host. For a request-level app, the ctx surface is:
\`\`\`js
ctx.theme // 'light' | 'dark' — also reflected on document.body className
ctx.response // { status, statusText, headers, data, dataBuffer?, size, duration, timeline } | null
ctx.assertionResults // array of assertion result objects
ctx.testResults // array of test result objects
ctx.variables // merged env + global + collection + runtime variables (read-only snapshot)
ctx.sendRequest(overrides?) // returns Promise<response>; overrides may carry { variables: {...} }
ctx.setRuntimeVariable(key, value) // persist a runtime variable on the collection
ctx.log(...args) // forwarded to the Bruno devtools console
ctx.onThemeChange = (theme) => { ... }
ctx.onResponseUpdate = (response) => { ... }
ctx.onResultsUpdate = ({ assertionResults, testResults }) => { ... }
ctx.onVariablesUpdate = (variables) => { ... }
\`\`\`
Theme changes automatically add a \`light\` or \`dark\` class on \`document.body\` — style both states.
## Best Practices
- Use modern JavaScript (async/await). Always handle loading and error states around \`ctx.sendRequest\`.
- Bind UI updates to the \`on*\` callbacks so the app reacts to host updates without polling.
- Do not rely on Bruno internals beyond \`ctx\`. Do not invent endpoints — the request URL/method is provided as HTTP Request Context.
- Keep CSS scoped to the app body; the webview is isolated but be a good guest.
## Output Rules
Return ONLY the raw HTML/CSS/JS for the app. No code fences, no commentary, no preamble. Begin with the first line of code (either a tag like \`<div>\` / \`<style>\` / \`<!DOCTYPE html>\`, or a \`<script>\` block).
If existing app code was provided, return the COMPLETE updated app (your output replaces the entire file). Preserve any existing markup or logic the user did not ask you to remove.`,
'app-collection': `You are an AI assistant that writes Bruno App code attached to a collection or folder.
## App Context
A Bruno App is a self-contained UI that runs inside a sandboxed <webview>. The user's code is injected into the body of a generated HTML document at runtime — it must be fully independent. Plain HTML, CSS, and JavaScript only. No bundler, no module imports, no JSX, no React import statements (React is allowed only if loaded inline via <script> tags + Babel from CDN). Output can be a bare HTML fragment or a full \`<html>\` document.
Before any user script runs, a global \`window.ctx\` is provided by the host. For a collection-/folder-level app, the ctx surface is:
\`\`\`js
ctx.theme // 'light' | 'dark' — also reflected on document.body className
ctx.variables // merged env + global + collection + runtime variables (read-only snapshot)
ctx.collection // { name, pathname } | null
ctx.listRequests() // returns Promise<Array<{ uid, name, pathname, type, method, url }>>
ctx.runRequest(pathname, overrides?) // runs a single request by its pathname; returns Promise<response>
ctx.setRuntimeVariable(key, value) // persist a runtime variable on the collection
ctx.log(...args) // forwarded to the Bruno devtools console
ctx.onThemeChange = (theme) => { ... }
ctx.onVariablesUpdate = (variables) => { ... }
\`\`\`
A collection-level app is NOT bound to a single request — use \`ctx.listRequests()\` to discover what is available and \`ctx.runRequest(pathname)\` to execute one. There is no \`ctx.response\` / \`ctx.sendRequest\` / \`ctx.assertionResults\` / \`ctx.testResults\` here — those exist only on request-level apps.
Theme changes automatically add a \`light\` or \`dark\` class on \`document.body\` — style both states.
## Best Practices
- Use modern JavaScript (async/await). Always handle loading and error states around \`ctx.runRequest\` and \`ctx.listRequests\`.
- Reference requests by the \`pathname\` returned from \`ctx.listRequests()\`, not by name — names can collide.
- When Documentation Context lists the collection's requests, you may pre-populate the UI with those names, but always discover via \`ctx.listRequests()\` at runtime so the app stays in sync as requests are added or renamed.
- Do not rely on Bruno internals beyond \`ctx\`.
## Output Rules
Return ONLY the raw HTML/CSS/JS for the app. No code fences, no commentary, no preamble. Begin with the first line of code (either a tag like \`<div>\` / \`<style>\` / \`<!DOCTYPE html>\`, or a \`<script>\` block).
If existing app code was provided, return the COMPLETE updated app (your output replaces the entire file). Preserve any existing markup or logic the user did not ask you to remove.`,
'docs': `You are an AI assistant that writes API documentation in Markdown for the Bruno API client.
## Documentation Context
@@ -250,8 +329,15 @@ const buildScriptUserPrompt = ({ userPrompt, currentScript, requestContext, docs
const contextStr = formatRequestContext(requestContext);
if (contextStr) sections.push(`HTTP Request Context\n${contextStr}`);
if (currentScript && currentScript.trim()) {
const existingLabel = scriptType === 'docs' ? 'Existing Documentation' : 'Existing Code';
const fenceLang = scriptType === 'docs' ? 'markdown' : 'js';
let existingLabel = 'Existing Code';
let fenceLang = 'js';
if (scriptType === 'docs') {
existingLabel = 'Existing Documentation';
fenceLang = 'markdown';
} else if (scriptType === 'app-request' || scriptType === 'app-collection') {
existingLabel = 'Existing App';
fenceLang = 'html';
}
sections.push(`${existingLabel}\n\`\`\`${fenceLang}\n${currentScript}\n\`\`\``);
}
sections.push(`User Request\n${userPrompt}`);

View File

@@ -1170,9 +1170,16 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
deleteRequestUid(pathname);
fs.unlinkSync(pathname);
} else if (type === 'app') {
// Standalone app items are single files with no per-line uid mapping.
if (!fs.existsSync(pathname)) {
return Promise.reject(new Error('The file does not exist'));
}
fs.unlinkSync(pathname);
} else {
return Promise.reject();
return Promise.reject(new Error(`Unsupported item type for delete: ${type}`));
}
} catch (error) {
return Promise.reject(error);

View File

@@ -13,6 +13,20 @@ export const parseBruRequest = (data: string | any, parsed: boolean = false): an
try {
const json = parsed ? data : bruToJsonV2(data);
if (_.get(json, 'meta.type') === 'app') {
const seq = _.get(json, 'meta.seq');
const tags = _.get(json, 'meta.tags', []);
return {
type: 'app',
name: _.get(json, 'meta.name'),
seq: !_.isNaN(seq) ? Number(seq) : 1,
tags: Array.isArray(tags) ? tags : [],
settings: _.get(json, 'settings', {}),
app: { code: _.get(json, 'app.code', null) },
request: null
};
}
let requestType = _.get(json, 'meta.type');
switch (requestType) {
case 'http':
@@ -122,6 +136,22 @@ export const parseBruRequest = (data: string | any, parsed: boolean = false): an
export const stringifyBruRequest = (json: any): string => {
try {
// Standalone app item — emit only meta + the app code block.
if (_.get(json, 'type') === 'app') {
const seq = _.get(json, 'seq');
const bruJson: any = {
meta: {
name: _.get(json, 'name'),
type: 'app',
seq: !_.isNaN(seq) ? Number(seq) : 1,
tags: _.get(json, 'tags', [])
},
settings: _.get(json, 'settings', {}),
app: { code: _.get(json, 'app.code', '') }
};
return jsonToBruV2(bruJson);
}
let type = _.get(json, 'type');
switch (type) {
case 'http-request':

View File

@@ -0,0 +1,37 @@
import type { Item as BrunoItem } from '@usebruno/schema-types/collection/item';
import { uuid } from '../../../utils';
export interface AppFile {
info: {
name?: string;
type: 'app';
seq?: number;
tags?: string[];
};
code?: string;
}
const parseApp = (ocApp: AppFile): BrunoItem => {
const info = ocApp.info || ({} as AppFile['info']);
const brunoItem: BrunoItem = {
uid: uuid(),
type: 'app',
seq: typeof info.seq === 'number' ? info.seq : 1,
name: info.name || 'App',
tags: Array.isArray(info.tags) ? info.tags : [],
request: null,
settings: null,
app: { code: ocApp.code || '' },
fileContent: null,
root: null,
items: [],
examples: [],
filename: null,
pathname: null
};
return brunoItem;
};
export default parseApp;

View File

@@ -0,0 +1,45 @@
import type { Item as BrunoItem } from '@usebruno/schema-types/collection/item';
import { stringifyYml } from '../utils';
interface AppFileInfo {
name: string;
type: 'app';
seq?: number;
tags?: string[];
}
interface AppFile {
info: AppFileInfo;
code?: string;
}
// Mirror the request shape (info.name/type/seq/tags + body) so apps round-trip
// the same fields and can be reordered via seq like every other item.
const stringifyApp = (item: BrunoItem): string => {
try {
const info: AppFileInfo = {
name: item.name && item.name.trim().length ? item.name : 'App',
type: 'app'
};
if (typeof item.seq === 'number') {
info.seq = item.seq;
}
if (Array.isArray(item.tags) && item.tags.length) {
info.tags = item.tags;
}
const ocApp: AppFile = { info };
const code = item.app?.code;
if (code && code.trim().length) {
ocApp.code = code;
}
return stringifyYml(ocApp);
} catch (error) {
console.error('Error stringifying app:', error);
throw error;
}
};
export default stringifyApp;

View File

@@ -10,6 +10,7 @@ import parseGraphQLRequest from './items/parseGraphQLRequest';
import parseGrpcRequest from './items/parseGrpcRequest';
import parseWebsocketRequest from './items/parseWebsocketRequest';
import parseScript from './items/parseScript';
import parseApp, { type AppFile } from './items/parseApp';
// Helper to get the type from an item (now in info block)
const getItemType = (item: Item): string | undefined => {
@@ -87,6 +88,9 @@ const parseItem = (ymlString: string): BrunoItem => {
case 'script':
return parseScript(ocItem as ScriptFile);
case 'app':
return parseApp(ocItem as unknown as AppFile);
case 'folder':
throw new Error('Folder items should be handled separately using parseFolder');

View File

@@ -4,6 +4,7 @@ import stringifyGraphqlRequest from './items/stringifyGraphQLRequest';
import stringifyGrpcRequest from './items/stringifyGrpcRequest';
import stringifyWebsocketRequest from './items/stringifyWebsocketRequest';
import stringifyScript from './items/stringifyScript';
import stringifyApp from './items/stringifyApp';
const stringifyItem = (item: BrunoItem): string => {
try {
@@ -23,6 +24,9 @@ const stringifyItem = (item: BrunoItem): string => {
case 'js':
return stringifyScript(item);
case 'app':
return stringifyApp(item);
case 'folder':
throw new Error('Folder items should be handled separately using stringifyFolder');

View File

@@ -8,6 +8,7 @@ export type ItemType
| 'graphql-request'
| 'folder'
| 'js'
| 'app'
| 'grpc-request'
| 'ws-request';

View File

@@ -636,7 +636,7 @@ const folderRootSchema = Yup.object({
const itemSchema = Yup.object({
uid: uidSchema,
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js', 'grpc-request', 'ws-request']).required('type is required'),
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js', 'app', 'grpc-request', 'ws-request']).required('type is required'),
seq: Yup.number().min(1),
name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
tags: Yup.array().of(Yup.string().min(1, 'tag must not be empty')),

View File

@@ -0,0 +1,216 @@
import { test, expect, ElectronApplication } from '../../playwright';
import {
createCollection,
createRequest,
createApp,
selectAppView,
selectRequestBodyMode,
saveRequest
} from '../utils/page';
/*
* The collection app's preview runs inside an out-of-process <webview> guest,
* so we evaluate in the Electron main process, locate the app webview, and
* execute JS inside it (mirrors apps-ctx-api.spec.ts).
*/
const guestEval = (
electronApp: ElectronApplication,
code: string,
expectedCollectionName?: string
) =>
electronApp.evaluate(
async ({ webContents }, params) => {
const guests = webContents.getAllWebContents().filter((wc) => {
try {
return wc.getType() === 'webview' && (wc.getURL() || '').startsWith('data:text/html');
} catch {
return false;
}
});
if (!params.expectedCollectionName) {
for (const guest of guests) {
try {
return await guest.executeJavaScript(params.code, true);
} catch {
/* try the next one */
}
}
return undefined;
}
for (const guest of guests) {
try {
const name = await guest.executeJavaScript(
'window.ctx && window.ctx.collection && window.ctx.collection.name',
true
);
if (name === params.expectedCollectionName) {
return await guest.executeJavaScript(params.code, true);
}
} catch {
}
}
return undefined;
},
{ code, expectedCollectionName }
);
const waitForGuestReady = async (electronApp: ElectronApplication, collectionName?: string) => {
await expect
.poll(async () => guestEval(electronApp, 'typeof window.ctx', collectionName), { timeout: 15000 })
.toBe('object');
};
// Set the CodeMirror editor in the active CollectionApp tab. We use the API
// directly to avoid auto-close-bracket corruption when typing HTML/JS.
const setCollectionAppCode = async (page, code: string) => {
await selectAppView(page, 'code');
const editor = page.getByTestId('collection-app-code').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);
};
// A minimal app that exposes helpers the test can drive from the host side.
// Writes its results into a single data-attribute we then poll.
const CTX_APP = `
<div id="out" data-result="pending">pending</div>
<script>
window.__listRequests = async function () {
const r = await ctx.listRequests();
document.getElementById('out').setAttribute('data-result', JSON.stringify(r.map(x => x.name)));
};
window.__runEcho = async function (pathname) {
try {
const res = await ctx.runRequest(pathname, { q: 'echoed' });
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
document.getElementById('out').setAttribute('data-result', JSON.stringify({ status: res.status, q: data && data.q }));
} catch (e) {
document.getElementById('out').setAttribute('data-result', 'ERR:' + e.message);
}
};
window.__readVar = function (key) {
document.getElementById('out').setAttribute('data-result', String(ctx.variables[key] ?? '(missing)'));
};
window.__readCollectionName = function () {
document.getElementById('out').setAttribute('data-result', String(ctx.collection && ctx.collection.name));
};
</script>`;
const ECHO_JSON_URL = 'http://localhost:8081/api/echo/json';
test.describe('Collection apps', () => {
test('Create from collection menu → appears in sidebar → opens as own tab with Code/Preview', async ({ page, createTmpDir }) => {
const collectionPath = await createTmpDir('collection-apps-create');
await createCollection(page, 'col-apps-create', collectionPath);
await createApp(page, 'My App', { collectionName: 'col-apps-create' });
await test.step('Sidebar item with app icon appears', async () => {
await expect(page.locator('.collection-item-name').filter({ hasText: 'My App' })).toBeVisible();
});
await test.step('Tab opens, Code/Preview toggle works', async () => {
await expect(page.getByTestId('collection-app')).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId('collection-app-view-preview')).toHaveClass(/active/);
await selectAppView(page, 'code');
await expect(page.getByTestId('collection-app-code')).toBeVisible();
await expect(page.getByTestId('collection-app-view-code')).toHaveClass(/active/);
await selectAppView(page, 'preview');
await expect(page.getByTestId('collection-app-preview').locator('webview')).toBeVisible();
});
});
test('ctx.listRequests sees every request in the collection', async ({ page, electronApp, createTmpDir }) => {
const collectionPath = await createTmpDir('collection-apps-list');
await createCollection(page, 'col-apps-list', collectionPath);
await createRequest(page, 'alpha', 'col-apps-list', { url: 'http://localhost:8081/ping' });
await createRequest(page, 'beta', 'col-apps-list', { url: 'http://localhost:8081/ping' });
await createApp(page, 'List App', { collectionName: 'col-apps-list' });
await setCollectionAppCode(page, CTX_APP);
await saveRequest(page);
await selectAppView(page, 'preview');
await waitForGuestReady(electronApp, 'col-apps-list');
await guestEval(electronApp, 'void window.__listRequests()', 'col-apps-list');
await expect
.poll(() => guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`, 'col-apps-list'), { timeout: 15000 })
.toBe(JSON.stringify(['alpha', 'beta']));
});
test('ctx.runRequest executes a request by pathname and reflects the response', async ({ page, electronApp, createTmpDir }) => {
const collectionPath = await createTmpDir('collection-apps-run');
await createCollection(page, 'col-apps-run', collectionPath);
await createRequest(page, 'echo', 'col-apps-run', { method: 'POST', url: ECHO_JSON_URL });
// Body referencing {{q}} so the override turns into the response payload.
await page.locator('.collection-item-name').filter({ hasText: 'echo' }).click();
await selectRequestBodyMode(page, 'JSON');
const bodyEditor = page.getByTestId('request-body-editor').locator('.CodeMirror').first();
await bodyEditor.waitFor({ state: 'visible' });
await bodyEditor.evaluate((el) => {
const cm = (el as any).CodeMirror;
if (cm) cm.setValue('{"q":"{{q}}"}');
});
await saveRequest(page);
await createApp(page, 'Runner App', { collectionName: 'col-apps-run' });
await setCollectionAppCode(page, CTX_APP);
await saveRequest(page);
await selectAppView(page, 'preview');
await waitForGuestReady(electronApp, 'col-apps-run');
// Resolve the pathname of the 'echo' request via ctx.listRequests, then run it.
await guestEval(
electronApp,
`(async () => {
const requests = await ctx.listRequests();
const echo = requests.find(r => r.name === 'echo');
await window.__runEcho(echo.pathname);
})()`,
'col-apps-run'
);
await expect
.poll(() => guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`, 'col-apps-run'), { timeout: 20000 })
.toBe(JSON.stringify({ status: 200, q: 'echoed' }));
});
test('ctx.setRuntimeVariable persists into ctx.variables', async ({ page, electronApp, createTmpDir }) => {
const collectionPath = await createTmpDir('collection-apps-vars');
await createCollection(page, 'col-apps-vars', collectionPath);
await createApp(page, 'Vars App', { collectionName: 'col-apps-vars' });
await setCollectionAppCode(page, CTX_APP);
await saveRequest(page);
await selectAppView(page, 'preview');
await waitForGuestReady(electronApp, 'col-apps-vars');
await guestEval(electronApp, `ctx.setRuntimeVariable('hello', 'world')`, 'col-apps-vars');
await expect
.poll(() => guestEval(electronApp, `ctx.variables && ctx.variables.hello`, 'col-apps-vars'), { timeout: 15000 })
.toBe('world');
});
test('ctx.collection exposes the active collection', async ({ page, electronApp, createTmpDir }) => {
const collectionPath = await createTmpDir('collection-apps-meta');
await createCollection(page, 'col-apps-meta', collectionPath);
await createApp(page, 'Meta App', { collectionName: 'col-apps-meta' });
await setCollectionAppCode(page, CTX_APP);
await saveRequest(page);
await selectAppView(page, 'preview');
await waitForGuestReady(electronApp, 'col-apps-meta');
await guestEval(electronApp, 'void window.__readCollectionName()', 'col-apps-meta');
await expect
.poll(() => guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`, 'col-apps-meta'), { timeout: 15000 })
.toBe('col-apps-meta');
});
});

View File

@@ -2027,6 +2027,55 @@ const getAppWebviewHtml = async (page: Page): Promise<string> => {
return decodeURIComponent(src.slice(comma + 1));
};
/**
* Create a standalone (collection-level or folder-level) app via the sidebar
* context menu. Opens the new tab once created.
* @param page - The page object
* @param appName - Name to give the new app
* @param parent - Either `{ collectionName }` for a collection-level app,
* or `{ collectionName, folderName }` for a folder-level app.
*/
const createApp = async (
page: Page,
appName: string,
parent: { collectionName: string; folderName?: string }
) => {
await test.step(`Create app "${appName}" in ${parent.folderName ? `folder "${parent.folderName}"` : `collection "${parent.collectionName}"`}`, async () => {
const locators = buildCommonLocators(page);
if (parent.folderName) {
const collectionScope = locators.sidebar.collectionScope(parent.collectionName);
const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: parent.folderName });
await folderRow.hover();
await folderRow.locator('.menu-icon').click();
} else {
await locators.sidebar.collection(parent.collectionName).hover();
const collectionAction = locators.actions.collectionActions(parent.collectionName);
await expect(collectionAction).toBeVisible({ timeout: 2000 });
await collectionAction.click();
}
await page.locator('.tippy-box:visible .dropdown-item').filter({ hasText: 'New App' }).click();
const modal = page.locator('.bruno-modal').filter({ hasText: 'New App' });
await expect(modal).toBeVisible({ timeout: 5000 });
await modal.locator('input[name="appName"]').fill(appName);
await modal.getByRole('button', { name: 'Create', exact: true }).click();
await expect(modal).toBeHidden({ timeout: 5000 });
});
};
/**
* Switch the CollectionApp tab between Code and Preview views.
* @param page - The page object
* @param view - 'code' | 'preview'
*/
const selectAppView = async (page: Page, view: 'code' | 'preview') => {
await test.step(`Switch collection app to "${view}"`, async () => {
await page.getByTestId(`collection-app-view-${view}`).click();
});
};
/**
* Rename a websocket message by double-clicking its label and typing a new name.
* @param page - The page object
@@ -2168,6 +2217,8 @@ export {
exitApp,
selectViewMode,
getAppWebviewHtml,
createApp,
selectAppView,
renameWsMessage
};