+
{
onSave={onSave}
mode="javascript"
/>
+
);
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index 359e962be..458464ce1 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -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 (
+
+
+
+
+
+ );
+ }
+
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', '');
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
index e79066250..15df5e719 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
@@ -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
}
}}
>
-
- {method}
-
+ {item.type === 'app' ? (
+
+
+
+ ) : (
+
+ {method}
+
+ )}
{item.name}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js
index 30927cfc4..251b1fb82 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js
@@ -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
;
}
+ if (item?.type === 'app') {
+ return
;
+ }
+
return
;
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
index 62b2b32c4..0ca06312b 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
@@ -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 && (
setNewFolderModalOpen(false)} />
)}
+ {newAppModalOpen && (
+ setNewAppModalOpen(false)} />
+ )}
{runCollectionModalOpen && (
setRunCollectionModalOpen(false)} />
)}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
index cf40e970f..8674a8c02 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
@@ -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 }) => {
{showNewRequestModal && setShowNewRequestModal(false)} />}
{showNewFolderModal && setShowNewFolderModal(false)} />}
+ {showNewAppModal && setShowNewAppModal(false)} />}
{showRenameCollectionModal && (
setShowRenameCollectionModal(false)} />
)}
diff --git a/packages/bruno-app/src/components/Sidebar/NewApp/index.js b/packages/bruno-app/src/components/Sidebar/NewApp/index.js
new file mode 100644
index 000000000..250f3ca7b
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/NewApp/index.js
@@ -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 (
+
+
+
+ );
+};
+
+export default NewApp;
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js
index 2ee601716..6942f3a42 100644
--- a/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js
@@ -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
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index db0da6353..843ffa5ef 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -1767,6 +1767,101 @@ export const newWsRequest = (params) => (dispatch, getState) => {
});
};
+const DEFAULT_APP_STARTER = `
+
+
+
+
+ App
+
+
+
+ Hello from a Bruno app
+ This app can list request in the collection.
+
+ click "List requests"
+
+
+
+`;
+
+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);
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 0818b5e26..a65dbdb54 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -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);
}
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 73eeba8af..20c8ea776 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -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) => ({
diff --git a/packages/bruno-electron/src/ipc/ai/script-prompts.js b/packages/bruno-electron/src/ipc/ai/script-prompts.js
index debcd81f0..df6a92a4f 100644
--- a/packages/bruno-electron/src/ipc/ai/script-prompts.js
+++ b/packages/bruno-electron/src/ipc/ai/script-prompts.js
@@ -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 . 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 `;
+
+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');
+ });
+});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index 0d1cf0184..a3a9f5342 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -2027,6 +2027,55 @@ const getAppWebviewHtml = async (page: Page): Promise => {
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
};