mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 23:54:24 +00:00
feat(app): introduce standalone app functionality (#8338)
This commit is contained in:
@@ -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;
|
||||
|
||||
48
packages/bruno-app/src/components/AppView/EmptyAppState.js
Normal file
48
packages/bruno-app/src/components/AppView/EmptyAppState.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
200
packages/bruno-app/src/components/AppView/webview-bridge.js
Normal file
200
packages/bruno-app/src/components/AppView/webview-bridge.js
Normal 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 };
|
||||
};
|
||||
@@ -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;
|
||||
394
packages/bruno-app/src/components/CollectionApp/index.js
Normal file
394
packages/bruno-app/src/components/CollectionApp/index.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', '');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
87
packages/bruno-app/src/components/Sidebar/NewApp/index.js
Normal file
87
packages/bruno-app/src/components/Sidebar/NewApp/index.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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':
|
||||
|
||||
37
packages/bruno-filestore/src/formats/yml/items/parseApp.ts
Normal file
37
packages/bruno-filestore/src/formats/yml/items/parseApp.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ItemType
|
||||
| 'graphql-request'
|
||||
| 'folder'
|
||||
| 'js'
|
||||
| 'app'
|
||||
| 'grpc-request'
|
||||
| 'ws-request';
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
216
tests/apps/collection-apps.spec.ts
Normal file
216
tests/apps/collection-apps.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user