mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat(app): introduce Enable App setting (#8521)
This commit is contained in:
@@ -43,9 +43,11 @@ const AppPreviewKeepAlive = () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
|
||||
const itemSource = item.draft ? item.draft : item;
|
||||
const appEnabled = get(itemSource, 'app.enabled', false) === true
|
||||
&& tab.appPreview !== false;
|
||||
if (appEnabled) {
|
||||
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
|
||||
const code = get(itemSource, 'app.code', '');
|
||||
out.push({ tabUid: tab.uid, collection, item, kind: 'request', code });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,32 @@ describe('AppPreviewKeepAlive', () => {
|
||||
expect(hiddenSlot).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('does not mount request apps when the app is not enabled', () => {
|
||||
const gatedItem = {
|
||||
...requestAppItem,
|
||||
uid: 'item-gated',
|
||||
app: { enabled: false, code: '<div>hi</div>' }
|
||||
};
|
||||
const gatedCollection = { uid: 'coll-1', items: [gatedItem] };
|
||||
const { store } = makeStore({
|
||||
tabs: [tabFor(gatedItem)],
|
||||
activeTabUid: gatedItem.uid,
|
||||
collections: [gatedCollection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not mount request apps whose tab has preview explicitly off', () => {
|
||||
const { store } = makeStore({
|
||||
tabs: [{ ...tabFor(requestAppItem), appPreview: false }],
|
||||
activeTabUid: requestAppItem.uid,
|
||||
collections: [collection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not mount app tabs that were never activated', () => {
|
||||
const { store } = makeStore({
|
||||
tabs: [tabFor(requestAppItem), tabFor(standaloneAppItem)],
|
||||
|
||||
@@ -10,10 +10,9 @@ import {
|
||||
import {
|
||||
responseReceived,
|
||||
appSetRuntimeVariable,
|
||||
toggleAppMode,
|
||||
initRunRequestEvent
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestPaneTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import Button from 'ui/Button';
|
||||
@@ -286,13 +285,13 @@ const AppView = ({ item, collection, code }) => {
|
||||
}, [variables, pushToGuest]);
|
||||
|
||||
const disableApp = useCallback(() => {
|
||||
dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid }));
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
dispatch(setTabAppPreview({ uid: item.uid, appPreview: false }));
|
||||
}, [dispatch, item.uid]);
|
||||
|
||||
const goToAppTab = useCallback(() => {
|
||||
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'app' }));
|
||||
dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid }));
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
dispatch(setTabAppPreview({ uid: item.uid, appPreview: false }));
|
||||
}, [dispatch, item.uid]);
|
||||
|
||||
const openAppsDocs = useCallback(() => {
|
||||
window?.ipcRenderer?.openExternal('https://link.usebruno.com/apps');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.app-toggle-row {
|
||||
.app-toolbar {
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconAppWindow } from '@tabler/icons';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildAiContextPayload } from 'utils/ai';
|
||||
import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateAppCode } from 'providers/ReduxStore/slices/collections';
|
||||
import { setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const AppCodeEditor = ({ item, collection }) => {
|
||||
@@ -16,13 +18,12 @@ const AppCodeEditor = ({ item, collection }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
|
||||
const enabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
|
||||
|
||||
const onEdit = (value) =>
|
||||
dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid }));
|
||||
|
||||
const onToggle = () =>
|
||||
dispatch(toggleAppMode({ enabled: !enabled, itemUid: item.uid, collectionUid: collection.uid }));
|
||||
const onPreview = () =>
|
||||
dispatch(setTabAppPreview({ uid: item.uid, appPreview: true }));
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
@@ -33,14 +34,19 @@ const AppCodeEditor = ({ item, collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full h-full flex flex-col">
|
||||
<div className="app-toggle-row mb-3 px-1 pb-3 flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium">Enable App</label>
|
||||
<p className="text-xs opacity-70">
|
||||
When enabled, replaces the request/response panes with the app view for this request.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch isOn={enabled} handleToggle={onToggle} size="xs" data-testid="app-enable-toggle" />
|
||||
<div className="app-toolbar mb-3 pb-3 flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted min-w-0">
|
||||
The app view replaces the request/response panes for this request.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<IconAppWindow size={14} strokeWidth={1.5} />}
|
||||
onClick={onPreview}
|
||||
className="flex-shrink-0"
|
||||
data-testid="app-preview-btn"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 app-editor relative" data-testid="app-code-editor">
|
||||
|
||||
@@ -59,7 +59,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
const getProperty = useCallback(
|
||||
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
|
||||
(key, defaultValue = []) => (item.draft ? get(item, `draft.${key}`, defaultValue) : get(item, key, defaultValue)),
|
||||
[item.draft, item]
|
||||
);
|
||||
|
||||
@@ -74,7 +74,10 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
const responseVars = getProperty('request.vars.res');
|
||||
const auth = getProperty('request.auth');
|
||||
const tags = getProperty('tags');
|
||||
const app = item.draft ? get(item, 'draft.app') : get(item, 'app');
|
||||
const app = getProperty('app', null);
|
||||
const appTabEnabled = app?.enabled === true;
|
||||
// A previously selected App tab may be restored while apps are disabled in settings.
|
||||
const effectiveTab = requestPaneTab === 'app' && !appTabEnabled ? 'params' : requestPaneTab;
|
||||
|
||||
const activeCounts = useMemo(() => ({
|
||||
params: params.filter((p) => p.enabled).length,
|
||||
@@ -116,14 +119,16 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, app, tags]);
|
||||
|
||||
const allTabs = useMemo(
|
||||
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
|
||||
[indicators]
|
||||
() => TAB_CONFIG
|
||||
.filter(({ key }) => key !== 'app' || appTabEnabled)
|
||||
.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
|
||||
[indicators, appTabEnabled]
|
||||
);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
const Component = TAB_PANELS[requestPaneTab];
|
||||
const Component = TAB_PANELS[effectiveTab];
|
||||
return Component ? <Component key={item.uid} item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
|
||||
}, [requestPaneTab, item, collection]);
|
||||
}, [effectiveTab, item, collection]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
@@ -143,7 +148,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
<div className="flex flex-col h-full relative">
|
||||
<ResponsiveTabs
|
||||
tabs={allTabs}
|
||||
activeTab={requestPaneTab}
|
||||
activeTab={effectiveTab}
|
||||
onTabSelect={selectTab}
|
||||
rightContent={rightContent}
|
||||
rightContentRef={rightContent ? rightContentRef : null}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { IconTag } from '@tabler/icons';
|
||||
import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
|
||||
import SettingsInput from 'components/SettingsInput';
|
||||
import InheritableSettingsInput from 'components/InheritableSettingsInput';
|
||||
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateItemSettings, toggleAppMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Tags from './Tags/index';
|
||||
|
||||
@@ -27,6 +28,7 @@ const Settings = ({ item, collection }) => {
|
||||
const rawSettings = getPropertyFromDraftOrRequest('settings');
|
||||
const settings = { ...DEFAULT_SETTINGS, ...rawSettings };
|
||||
const { encodeUrl, followRedirects, maxRedirects, timeout } = settings;
|
||||
const enableApp = getPropertyFromDraftOrRequest('app.enabled') === true;
|
||||
|
||||
// Reusable function to update settings
|
||||
const updateSetting = useCallback((settingUpdate) => {
|
||||
@@ -45,6 +47,14 @@ const Settings = ({ item, collection }) => {
|
||||
const onToggleFollowRedirects = useCallback(() =>
|
||||
updateSetting({ followRedirects: !followRedirects }), [followRedirects, updateSetting]);
|
||||
|
||||
const onToggleEnableApp = useCallback(() => {
|
||||
const next = !enableApp;
|
||||
dispatch(toggleAppMode({ enabled: next, itemUid: item.uid, collectionUid: collection.uid }));
|
||||
if (next) {
|
||||
dispatch(setTabAppPreview({ uid: item.uid, appPreview: false }));
|
||||
}
|
||||
}, [enableApp, dispatch, item.uid, collection.uid]);
|
||||
|
||||
const onMaxRedirectsChange = useCallback((e) => {
|
||||
const value = e.target.value;
|
||||
// Only allow empty string or digits
|
||||
@@ -131,6 +141,17 @@ const Settings = ({ item, collection }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToggleSelector
|
||||
checked={enableApp}
|
||||
onChange={onToggleEnableApp}
|
||||
label="Enable App"
|
||||
description="Show the App tab and app view mode for this request"
|
||||
size="medium"
|
||||
data-testid="enable-app-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsInput
|
||||
id="maxRedirects"
|
||||
label="Max Redirects"
|
||||
|
||||
@@ -578,8 +578,12 @@ const RequestTabPanel = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const itemSource = item.draft ? item.draft : item;
|
||||
// Preview state is runtime-only, kept on the tab; unset means "preview on" so
|
||||
// an app-enabled request opens in preview mode by default.
|
||||
const appEnabled = item.type !== 'app'
|
||||
&& (item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false));
|
||||
&& get(itemSource, 'app.enabled', false) === true
|
||||
&& focusedTab.appPreview !== false;
|
||||
if (item.type === 'app' || appEnabled) {
|
||||
return <StyledWrapper className="flex flex-col flex-grow relative overflow-hidden" data-testid="app-tab-placeholder" />;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionFileMode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat';
|
||||
import MigrateToYmlModal from 'components/CollectionSettings/Overview/Migration/MigrateToYmlModal';
|
||||
import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
|
||||
import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab, focusTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
@@ -82,13 +82,15 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
|| (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null))
|
||||
: null;
|
||||
const isHttpRequestActive = activeItem?.type === 'http-request';
|
||||
const appEnabled = activeItem
|
||||
? (activeItem.draft ? get(activeItem, 'draft.app.enabled', false) : get(activeItem, 'app.enabled', false))
|
||||
: false;
|
||||
const activeItemSource = activeItem ? activeItem.draft || activeItem : null;
|
||||
// The "Enable App" request setting (persisted as app.enabled) gates the whole
|
||||
// Request/App/File mode toggle.
|
||||
const appAvailable = isHttpRequestActive && get(activeItemSource, 'app.enabled', false) === true;
|
||||
const appEnabled = appAvailable && focusedTab?.appPreview !== false;
|
||||
|
||||
const handleToggleAppMode = (enabled) => {
|
||||
if (isHttpRequestActive) {
|
||||
dispatch(toggleAppMode({ enabled, itemUid: activeItem.uid, collectionUid: collection.uid }));
|
||||
dispatch(setTabAppPreview({ uid: focusedTab.uid, appPreview: enabled }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -296,9 +298,9 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
// Build overflow menu items for the "..." dropdown
|
||||
const overflowMenuItems = [
|
||||
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
|
||||
// File mode is exposed via the Request/App/File view-mode toggle when a request is active;
|
||||
// keep it in the overflow as a fallback for non-request contexts.
|
||||
...(!isHttpRequestActive
|
||||
// File mode is exposed via the Request/App/File view-mode toggle when the active
|
||||
// request has apps enabled; keep it in the overflow as a fallback everywhere else.
|
||||
...(!appAvailable
|
||||
? [{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick }]
|
||||
: []),
|
||||
...(!hasOpenApiSyncConfigured
|
||||
@@ -653,7 +655,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
<div className="flex flex-grow gap-1.5 items-center justify-end">
|
||||
{!isScratchCollection && (
|
||||
<>
|
||||
{isHttpRequestActive && (
|
||||
{appAvailable && (
|
||||
<div className="mode-toggle" data-testid="view-mode-toggle">
|
||||
<ToolHint text="Request" toolhintId="ViewModeRequestToolhintId" place="bottom">
|
||||
<button
|
||||
|
||||
@@ -2997,16 +2997,7 @@ export const collectionsSlice = createSlice({
|
||||
item.request = mergeRequestWithPreservedUids(item.request, file.data.request);
|
||||
item.settings = file.data.settings;
|
||||
item.examples = file.data.examples;
|
||||
// app.enabled is runtime-only and not persisted, so preserve it across file reloads
|
||||
// even when the file no longer has an `app` block on disk.
|
||||
const currentEnabled = item.draft?.app?.enabled ?? item.app?.enabled ?? false;
|
||||
if (file.data.app) {
|
||||
item.app = { ...file.data.app, enabled: currentEnabled };
|
||||
} else if (currentEnabled) {
|
||||
item.app = { code: null, enabled: true };
|
||||
} else {
|
||||
item.app = null;
|
||||
}
|
||||
item.app = file.data.app ? { ...file.data.app } : null;
|
||||
item.filename = file.meta.name;
|
||||
item.pathname = file.meta.pathname;
|
||||
item.raw = file.data.raw;
|
||||
@@ -3428,12 +3419,11 @@ export const collectionsSlice = createSlice({
|
||||
|
||||
const item = findItemInCollection(collection, action.payload.itemUid);
|
||||
if (item && isItemARequest(item)) {
|
||||
item.app = item.app || {};
|
||||
item.app.enabled = action.payload.enabled;
|
||||
if (item.draft) {
|
||||
item.draft.app = item.draft.app || {};
|
||||
item.draft.app.enabled = action.payload.enabled;
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.app = item.draft.app || {};
|
||||
item.draft.app.enabled = action.payload.enabled;
|
||||
}
|
||||
},
|
||||
appSetRuntimeVariable: (state, action) => {
|
||||
|
||||
@@ -507,6 +507,13 @@ export const tabsSlice = createSlice({
|
||||
tab.tabState = { ...tab.tabState, ...tabState };
|
||||
}
|
||||
},
|
||||
setTabAppPreview: (state, action) => {
|
||||
const { uid, appPreview } = action.payload;
|
||||
const tab = find(state.tabs, (t) => t.uid === uid);
|
||||
if (tab) {
|
||||
tab.appPreview = appPreview;
|
||||
}
|
||||
},
|
||||
reopenLastClosedTab: (state, action) => {
|
||||
const collectionUid = action.payload?.collectionUid;
|
||||
// Find the last closed tab for this collection (LIFO). If no collectionUid is
|
||||
@@ -558,6 +565,7 @@ export const {
|
||||
syncTabUid,
|
||||
restoreTabs,
|
||||
updateTabState,
|
||||
setTabAppPreview,
|
||||
reopenLastClosedTab,
|
||||
updateQueryBuilderOpen,
|
||||
updateQueryBuilderWidth,
|
||||
|
||||
@@ -724,8 +724,8 @@ export const transformRequestToSaveToFilesystem = (item) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const appToSave = _item.app && _item.app.code && _item.app.code.length
|
||||
? { code: _item.app.code }
|
||||
const appToSave = _item.app && (_item.app.enabled === true || (_item.app.code && _item.app.code.length))
|
||||
? { code: _item.app.code || null, enabled: _item.app.enabled === true }
|
||||
: null;
|
||||
|
||||
const itemToSave = {
|
||||
|
||||
@@ -54,7 +54,9 @@ export const parseBruRequest = (data: string | any, parsed: boolean = false): an
|
||||
};
|
||||
|
||||
const appData = _.get(json, 'app');
|
||||
const app = appData ? { code: _.get(appData, 'code', null) } : null;
|
||||
const app = appData
|
||||
? { code: _.get(appData, 'code', null), enabled: _.get(appData, 'enabled', false) === true }
|
||||
: null;
|
||||
|
||||
const transformedJson = {
|
||||
type: requestType,
|
||||
@@ -257,8 +259,8 @@ export const stringifyBruRequest = (json: any): string => {
|
||||
bruJson.examples = _.get(json, 'examples', []).map((e: any) => jsonExampleToBru(e));
|
||||
|
||||
const app = _.get(json, 'app');
|
||||
if (app && app.code && app.code.length) {
|
||||
bruJson.app = { code: app.code };
|
||||
if (app && (app.enabled === true || (app.code && app.code.length))) {
|
||||
bruJson.app = { code: app.code || null, enabled: app.enabled === true };
|
||||
}
|
||||
|
||||
const bru = jsonToBruV2(bruJson);
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { App as BrunoApp } from '@usebruno/schema-types/collection/item';
|
||||
|
||||
export interface OpenCollectionApp {
|
||||
enabled?: boolean;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export const toOpenCollectionApp = (app: BrunoApp | null | undefined): OpenCollectionApp | undefined => {
|
||||
if (!app || !app.code) return undefined;
|
||||
return { code: app.code };
|
||||
// The app block persists when enabled even before any code is written.
|
||||
if (!app || (!app.code && app.enabled !== true)) return undefined;
|
||||
const ocApp: OpenCollectionApp = {};
|
||||
if (app.enabled === true) {
|
||||
ocApp.enabled = true;
|
||||
}
|
||||
if (app.code) {
|
||||
ocApp.code = app.code;
|
||||
}
|
||||
return ocApp;
|
||||
};
|
||||
|
||||
export const toBrunoApp = (app: OpenCollectionApp | null | undefined): BrunoApp | null => {
|
||||
if (!app) return null;
|
||||
return {
|
||||
enabled: false,
|
||||
enabled: app.enabled === true,
|
||||
code: app.code || null
|
||||
};
|
||||
};
|
||||
|
||||
@@ -338,6 +338,9 @@ const createGetNumFromRecord = (obj) => (key, { fallback } = {}) => {
|
||||
return asNumber;
|
||||
};
|
||||
|
||||
// Dictionary values arrive as strings; booleans may already be coerced upstream.
|
||||
const toBool = (value) => (typeof value === 'boolean' ? value : value === 'true');
|
||||
|
||||
// Parse example content using dedicated example parser
|
||||
const parseExampleContent = (content) => {
|
||||
try {
|
||||
@@ -538,7 +541,8 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
const appData = mapPairListToKeyValPair(dictionary.ast);
|
||||
return {
|
||||
app: {
|
||||
code: appData.code || null
|
||||
code: appData.code || null,
|
||||
enabled: toBool(appData.enabled)
|
||||
}
|
||||
};
|
||||
},
|
||||
@@ -550,7 +554,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
|
||||
const parsedSettings = {};
|
||||
if (settings.followRedirects !== undefined) {
|
||||
parsedSettings.followRedirects = typeof settings.followRedirects === 'boolean' ? settings.followRedirects : settings.followRedirects === 'true';
|
||||
parsedSettings.followRedirects = toBool(settings.followRedirects);
|
||||
}
|
||||
|
||||
// Parse maxRedirects as number
|
||||
@@ -574,7 +578,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
|
||||
const _settings = {
|
||||
encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true',
|
||||
encodeUrl: toBool(settings.encodeUrl),
|
||||
timeout: parsedSettings.timeout !== undefined ? parsedSettings.timeout : 0
|
||||
};
|
||||
|
||||
|
||||
@@ -770,10 +770,15 @@ ${indentString(tests)}
|
||||
`;
|
||||
}
|
||||
|
||||
if (app && app.code && app.code.length) {
|
||||
if (app && (app.enabled === true || (app.code && app.code.length))) {
|
||||
bru += `app {\n`;
|
||||
bru += ` code: '''\n${indentString(app.code, 2)}\n '''`;
|
||||
bru += '\n}\n\n';
|
||||
if (app.enabled === true) {
|
||||
bru += ` enabled: true\n`;
|
||||
}
|
||||
if (app.code && app.code.length) {
|
||||
bru += ` code: '''\n${indentString(app.code, 2)}\n '''\n`;
|
||||
}
|
||||
bru += '}\n\n';
|
||||
}
|
||||
|
||||
if (settings && Object.keys(settings).length) {
|
||||
|
||||
49
packages/bruno-lang/v2/tests/app.spec.js
Normal file
49
packages/bruno-lang/v2/tests/app.spec.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const bruToJson = require('../src/bruToJson');
|
||||
const jsonToBru = require('../src/jsonToBru');
|
||||
|
||||
describe('app block', () => {
|
||||
const appBruEnabled = `app {
|
||||
enabled: true
|
||||
code: '''
|
||||
<div>hello</div>
|
||||
'''
|
||||
}
|
||||
`;
|
||||
|
||||
const appBruDisabled = `app {
|
||||
code: '''
|
||||
<div>hello</div>
|
||||
'''
|
||||
}
|
||||
`;
|
||||
|
||||
it('should parse an app block without enabled as disabled', () => {
|
||||
const output = bruToJson(appBruDisabled);
|
||||
expect(output.app).toEqual({ code: '<div>hello</div>', enabled: false });
|
||||
});
|
||||
|
||||
it('should parse enabled: true', () => {
|
||||
const output = bruToJson(appBruEnabled);
|
||||
expect(output.app).toEqual({ code: '<div>hello</div>', enabled: true });
|
||||
});
|
||||
|
||||
it('should stringify enabled only when enabled', () => {
|
||||
expect(jsonToBru({ app: { code: '<div>hello</div>', enabled: true } })).toEqual(appBruEnabled);
|
||||
expect(jsonToBru({ app: { code: '<div>hello</div>', enabled: false } })).toEqual(appBruDisabled);
|
||||
});
|
||||
|
||||
it('should round-trip the enabled flag', () => {
|
||||
expect(bruToJson(jsonToBru({ app: { code: '<x/>', enabled: true } })).app.enabled).toBe(true);
|
||||
expect(bruToJson(jsonToBru({ app: { code: '<x/>', enabled: false } })).app.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should serialize an enabled app block even without code', () => {
|
||||
const bru = jsonToBru({ app: { code: null, enabled: true } });
|
||||
expect(bru).toEqual('app {\n enabled: true\n}\n');
|
||||
expect(bruToJson(bru).app).toEqual({ code: null, enabled: true });
|
||||
});
|
||||
|
||||
it('should omit the app block when disabled and without code', () => {
|
||||
expect(jsonToBru({ app: { code: null, enabled: false } })).toEqual('');
|
||||
});
|
||||
});
|
||||
@@ -677,7 +677,7 @@ const itemSchema = Yup.object({
|
||||
encodeUrl: Yup.boolean().nullable(),
|
||||
followRedirects: Yup.boolean().nullable(),
|
||||
maxRedirects: Yup.number().min(0).max(50).nullable(),
|
||||
timeout: Yup.mixed().nullable(),
|
||||
timeout: Yup.mixed().nullable()
|
||||
}).noUnknown(true)
|
||||
.strict()
|
||||
.nullable()
|
||||
@@ -702,7 +702,8 @@ const itemSchema = Yup.object({
|
||||
otherwise: Yup.array().strip()
|
||||
}),
|
||||
app: Yup.object({
|
||||
code: Yup.string().nullable()
|
||||
code: Yup.string().nullable(),
|
||||
enabled: Yup.boolean().nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.nullable(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createRequest,
|
||||
openRequest,
|
||||
setAppCode,
|
||||
enableApp,
|
||||
previewApp,
|
||||
saveRequest,
|
||||
selectRequestBodyMode,
|
||||
getAppWebviewHtml
|
||||
@@ -87,7 +87,7 @@ test.describe('Apps - ctx API', () => {
|
||||
await openRequest(page, 'apps-ctx', 'ctx-req', { persist: true });
|
||||
|
||||
await setAppCode(page, CTX_APP);
|
||||
await enableApp(page);
|
||||
await previewApp(page);
|
||||
await waitForGuestReady(electronApp);
|
||||
|
||||
const raw = await guestEval(
|
||||
@@ -118,7 +118,7 @@ test.describe('Apps - ctx API', () => {
|
||||
await openRequest(page, 'apps-theme', 'theme-req', { persist: true });
|
||||
|
||||
await setAppCode(page, CTX_APP);
|
||||
await enableApp(page);
|
||||
await previewApp(page);
|
||||
await waitForGuestReady(electronApp);
|
||||
|
||||
const raw = await guestEval(
|
||||
@@ -137,7 +137,7 @@ test.describe('Apps - ctx API', () => {
|
||||
await openRequest(page, 'apps-log', 'log-req', { persist: true });
|
||||
|
||||
await setAppCode(page, CTX_APP);
|
||||
await enableApp(page);
|
||||
await previewApp(page);
|
||||
await waitForGuestReady(electronApp);
|
||||
|
||||
const result = await guestEval(electronApp, 'window.__log()');
|
||||
@@ -153,7 +153,7 @@ test.describe('Apps - ctx API', () => {
|
||||
await setJsonBodyWithVar(page);
|
||||
await setAppCode(page, CTX_APP);
|
||||
await saveRequest(page);
|
||||
await enableApp(page);
|
||||
await previewApp(page);
|
||||
await waitForGuestReady(electronApp);
|
||||
|
||||
await test.step('flat override keys become runtime variables', async () => {
|
||||
@@ -176,7 +176,7 @@ test.describe('Apps - ctx API', () => {
|
||||
await setJsonBodyWithVar(page);
|
||||
await setAppCode(page, CTX_APP);
|
||||
await saveRequest(page);
|
||||
await enableApp(page);
|
||||
await previewApp(page);
|
||||
await waitForGuestReady(electronApp);
|
||||
|
||||
await guestEval(electronApp, `window.__setVar('q', 'viaSet')`);
|
||||
@@ -197,7 +197,7 @@ test.describe('Apps - ctx API', () => {
|
||||
await openRequest(page, 'apps-boot', 'boot-req', { persist: true });
|
||||
|
||||
await setAppCode(page, CTX_APP);
|
||||
await enableApp(page);
|
||||
await previewApp(page);
|
||||
|
||||
const html = await getAppWebviewHtml(page);
|
||||
// ctx API surface is present in the injected bootstrap
|
||||
|
||||
@@ -5,7 +5,13 @@ import {
|
||||
openRequest,
|
||||
selectRequestPaneTab,
|
||||
setAppCode,
|
||||
enableApp,
|
||||
setAppEnabled,
|
||||
readAppEditor,
|
||||
requestPaneAppTab,
|
||||
openRequestPaneTabOverflow,
|
||||
requestPaneOverflowTabItem,
|
||||
activeAppView,
|
||||
previewApp,
|
||||
exitApp,
|
||||
selectViewMode,
|
||||
saveRequest,
|
||||
@@ -14,16 +20,50 @@ import {
|
||||
|
||||
const SIMPLE_APP = `<div id="hello">Hello from the app</div>`;
|
||||
|
||||
// Read the app code currently loaded in the App-tab editor (via the CodeMirror API).
|
||||
const readAppEditor = (page) =>
|
||||
page
|
||||
.getByTestId('app-code-editor')
|
||||
.locator('.CodeMirror')
|
||||
.first()
|
||||
.evaluate((el) => (el as any).CodeMirror?.getValue());
|
||||
// Assert the App tab is absent from the request pane — checks both the visible
|
||||
// tab row and the ResponsiveTabs overflow dropdown (tabs can overflow at narrow widths).
|
||||
const expectNoAppTab = async (page) => {
|
||||
await expect(requestPaneAppTab(page)).toHaveCount(0);
|
||||
await openRequestPaneTabOverflow(page);
|
||||
await expect(requestPaneOverflowTabItem(page, /^App$/)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
};
|
||||
|
||||
test.describe('Apps - request-level UI', () => {
|
||||
test('App tab: enable takes over the panes, exit returns to the editor', async ({ page, createTmpDir }) => {
|
||||
test('App tab and view-mode toggle are gated behind the Enable App setting', async ({ page, createTmpDir }) => {
|
||||
const collectionPath = await createTmpDir('apps-ui-gating');
|
||||
const collectionName = 'apps-gate';
|
||||
await createCollection(page, collectionName, collectionPath);
|
||||
await createRequest(page, 'gate-req', collectionName, {
|
||||
url: 'http://localhost:8081/api/echo/anything/x',
|
||||
method: 'GET'
|
||||
});
|
||||
await openRequest(page, collectionName, 'gate-req', { persist: true });
|
||||
|
||||
await test.step('App tab and view-mode toggle are hidden by default', async () => {
|
||||
await expect(page.getByTestId('responsive-tab-params')).toBeVisible();
|
||||
await expectNoAppTab(page);
|
||||
await expect(page.getByTestId('view-mode-toggle')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await setAppEnabled(page, true);
|
||||
|
||||
await test.step('Enabling in settings reveals the App tab and view-mode toggle', async () => {
|
||||
await expect(page.getByTestId('view-mode-toggle')).toBeVisible();
|
||||
// selecting the tab proves it exists (handles the overflow dropdown too)
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
await expect(page.getByTestId('app-code-editor')).toBeVisible();
|
||||
});
|
||||
|
||||
await setAppEnabled(page, false);
|
||||
|
||||
await test.step('Disabling hides them again', async () => {
|
||||
await expectNoAppTab(page);
|
||||
await expect(page.getByTestId('view-mode-toggle')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('App tab: Preview takes over the panes, exit returns to the editor', async ({ page, createTmpDir }) => {
|
||||
const collectionPath = await createTmpDir('apps-ui-toggle');
|
||||
await createCollection(page, 'apps-ui', collectionPath);
|
||||
await createRequest(page, 'app-req', 'apps-ui', {
|
||||
@@ -32,19 +72,18 @@ test.describe('Apps - request-level UI', () => {
|
||||
});
|
||||
await openRequest(page, 'apps-ui', 'app-req', { persist: true });
|
||||
|
||||
await test.step('App tab exposes the toggle and editor', async () => {
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
await expect(page.getByTestId('app-enable-toggle')).toBeVisible();
|
||||
await setAppCode(page, SIMPLE_APP);
|
||||
|
||||
await test.step('App tab exposes the Preview button and editor', async () => {
|
||||
await expect(page.getByTestId('app-preview-btn')).toBeVisible();
|
||||
await expect(page.getByTestId('app-code-editor')).toBeVisible();
|
||||
// request pane is still the normal request view while disabled
|
||||
// request pane is still the normal request view before previewing
|
||||
await expect(page.getByTestId('request-pane')).toBeVisible();
|
||||
});
|
||||
|
||||
await setAppCode(page, SIMPLE_APP);
|
||||
|
||||
await test.step('Enabling app mode replaces the request/response area with the app view', async () => {
|
||||
await enableApp(page);
|
||||
await expect(page.getByTestId('app-view').locator('webview')).toBeVisible();
|
||||
await test.step('Preview replaces the request/response area with the app view', async () => {
|
||||
await previewApp(page);
|
||||
await expect(activeAppView(page).locator('webview')).toBeVisible();
|
||||
await expect(page.getByTestId('request-pane')).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -61,12 +100,13 @@ test.describe('Apps - request-level UI', () => {
|
||||
await createRequest(page, 'ind-req', 'apps-ind', { url: 'http://localhost:8081/api/echo/anything/x' });
|
||||
await openRequest(page, 'apps-ind', 'ind-req', { persist: true });
|
||||
|
||||
await setAppEnabled(page, true);
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
// No code yet → no indicator
|
||||
await expect(page.getByTestId('responsive-tab-app').getByTestId('status-dot-app')).toHaveCount(0);
|
||||
await expect(requestPaneAppTab(page).getByTestId('status-dot-app')).toHaveCount(0);
|
||||
|
||||
await setAppCode(page, SIMPLE_APP);
|
||||
await expect(page.getByTestId('responsive-tab-app').getByTestId('status-dot-app')).toBeVisible();
|
||||
await expect(requestPaneAppTab(page).getByTestId('status-dot-app')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Collection toolbar view-mode toggle switches Request / App / File', async ({ page, createTmpDir }) => {
|
||||
@@ -84,13 +124,13 @@ test.describe('Apps - request-level UI', () => {
|
||||
await test.step('Switch to App mode', async () => {
|
||||
await selectViewMode(page, 'app');
|
||||
await expect(page.getByTestId('view-mode-app')).toHaveClass(/active/);
|
||||
await expect(page.getByTestId('app-view').locator('webview')).toBeVisible();
|
||||
await expect(activeAppView(page).locator('webview')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch to File mode (app view goes away)', async () => {
|
||||
await selectViewMode(page, 'file');
|
||||
await expect(page.getByTestId('view-mode-file')).toHaveClass(/active/);
|
||||
await expect(page.getByTestId('app-view')).toBeHidden();
|
||||
await expect(activeAppView(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Switch back to Request mode', async () => {
|
||||
@@ -100,22 +140,31 @@ test.describe('Apps - request-level UI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('App code persists across save + reopen', async ({ page, createTmpDir }) => {
|
||||
test('Enable App and code persist; an enabled app opens in preview mode by default', async ({ page, createTmpDir }) => {
|
||||
const collectionPath = await createTmpDir('apps-ui-persist');
|
||||
await createCollection(page, 'apps-persist', collectionPath);
|
||||
await createRequest(page, 'persist-req', 'apps-persist', { url: 'http://localhost:8081/api/echo/anything/x' });
|
||||
await openRequest(page, 'apps-persist', 'persist-req', { persist: true });
|
||||
|
||||
// Enabling from Settings keeps the tab in request mode so code can be written.
|
||||
await setAppCode(page, SIMPLE_APP);
|
||||
await expect(page.getByTestId('request-pane')).toBeVisible();
|
||||
await saveRequest(page);
|
||||
|
||||
await closeAllTabs(page);
|
||||
|
||||
await openRequest(page, 'apps-persist', 'persist-req', { persist: true });
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
await expect(page.getByTestId('app-code-editor')).toBeVisible();
|
||||
await expect.poll(() => readAppEditor(page)).toBe(SIMPLE_APP);
|
||||
// App mode starts disabled on reopen (enabled is runtime-only, not persisted)
|
||||
await expect(page.getByTestId('app-enable-toggle')).toBeVisible();
|
||||
|
||||
await test.step('Freshly opened app-enabled request starts in preview mode', async () => {
|
||||
await expect(activeAppView(page).locator('webview')).toBeVisible();
|
||||
await expect(page.getByTestId('view-mode-app')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
await test.step('App code survives the round-trip', async () => {
|
||||
await exitApp(page);
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
await expect(page.getByTestId('app-code-editor')).toBeVisible();
|
||||
await expect.poll(() => readAppEditor(page)).toBe(SIMPLE_APP);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createRequest,
|
||||
createApp,
|
||||
selectAppView,
|
||||
activeAppPreviewSlot,
|
||||
selectRequestBodyMode,
|
||||
saveRequest
|
||||
} from '../utils/page';
|
||||
@@ -64,7 +65,7 @@ const waitForGuestReady = async (electronApp: ElectronApplication, collectionNam
|
||||
// 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();
|
||||
const editor = activeAppPreviewSlot(page).getByTestId('collection-app-code').locator('.CodeMirror').first();
|
||||
await editor.waitFor({ state: 'visible' });
|
||||
await editor.evaluate((el, val) => {
|
||||
const cm = (el as any).CodeMirror;
|
||||
@@ -112,13 +113,13 @@ test.describe('Collection apps', () => {
|
||||
});
|
||||
|
||||
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 expect(activeAppPreviewSlot(page).getByTestId('collection-app')).toBeVisible({ timeout: 5000 });
|
||||
await expect(activeAppPreviewSlot(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 expect(activeAppPreviewSlot(page).getByTestId('collection-app-code')).toBeVisible();
|
||||
await expect(activeAppPreviewSlot(page).getByTestId('collection-app-view-code')).toHaveClass(/active/);
|
||||
await selectAppView(page, 'preview');
|
||||
await expect(page.getByTestId('collection-app-preview').locator('webview')).toBeVisible();
|
||||
await expect(activeAppPreviewSlot(page).getByTestId('collection-app-preview').locator('webview')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { buildCommonLocators, openAiPreferences } from '../../utils/page';
|
||||
|
||||
const expectedModifier = process.platform === 'darwin' ? '⌘' : 'Ctrl';
|
||||
|
||||
test.describe('AI shortcut hints', () => {
|
||||
test('autocomplete keymap shows the platform-correct modifier', async ({ pageWithUserData: page }) => {
|
||||
await page.locator('[data-app-state="loaded"]').waitFor();
|
||||
const { ai } = buildCommonLocators(page);
|
||||
|
||||
await page.locator('.status-bar button[data-trigger="preferences"]').click();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await page.getByTestId('ai-tab-autocomplete').click();
|
||||
await openAiPreferences(page, 'autocomplete');
|
||||
|
||||
const keymap = page.locator('.autocomplete-keymap');
|
||||
await expect(keymap).toBeVisible();
|
||||
await expect(keymap.getByText(expectedModifier, { exact: true })).toBeVisible();
|
||||
await expect(ai.autocompleteKeymap()).toBeVisible();
|
||||
await expect(ai.autocompleteKeymapKey(expectedModifier)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2130,16 +2130,37 @@ const generateCollectionDocs = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the request's app code. Opens the App tab and writes the editor value
|
||||
* Toggle the "Enable App" request setting idempotently (Settings tab).
|
||||
* Enabling exposes the App tab and the Request/App/File view-mode toggle.
|
||||
* @param page - The page object
|
||||
* @param enabled - Whether apps should be enabled for the request
|
||||
*/
|
||||
const setAppEnabled = async (page: Page, enabled: boolean) => {
|
||||
await test.step(`Set Enable App ${enabled ? 'ON' : 'OFF'}`, async () => {
|
||||
await selectRequestPaneTab(page, 'Settings');
|
||||
const toggle = page.getByTestId('enable-app-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
const current = (await toggle.getAttribute('aria-checked')) === 'true';
|
||||
if (current !== enabled) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute('aria-checked', String(enabled));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the request's app code. Enables apps in the request settings (the App
|
||||
* tab is hidden otherwise), opens the App tab and writes the editor value
|
||||
* directly via the CodeMirror API (avoids auto-close-bracket corruption when
|
||||
* typing HTML/JS char-by-char). The app must not be enabled (editor visible).
|
||||
* typing HTML/JS char-by-char). The app view must not be active (editor visible).
|
||||
* @param page - The page object
|
||||
* @param code - The HTML/JS app code
|
||||
*/
|
||||
const setAppCode = async (page: Page, code: string) => {
|
||||
await setAppEnabled(page, true);
|
||||
await test.step('Set app code', async () => {
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
const editor = page.getByTestId('app-code-editor').locator('.CodeMirror').first();
|
||||
const editor = appCodeEditor(page);
|
||||
await editor.waitFor({ state: 'visible' });
|
||||
await editor.evaluate((el, val) => {
|
||||
const cm = (el as any).CodeMirror;
|
||||
@@ -2149,15 +2170,70 @@ const setAppCode = async (page: Page, code: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable app mode via the App tab's "Enable App" toggle. Asserts the app view
|
||||
* The CodeMirror element of the request-level App-tab editor.
|
||||
* @param page - The page object
|
||||
*/
|
||||
const appCodeEditor = (page: Page) => page.getByTestId('app-code-editor').locator('.CodeMirror').first();
|
||||
|
||||
/**
|
||||
* Read the app code currently loaded in the App-tab editor (via the CodeMirror API).
|
||||
* @param page - The page object
|
||||
* @returns The editor's current value
|
||||
*/
|
||||
const readAppEditor = (page: Page): Promise<string | undefined> =>
|
||||
appCodeEditor(page).evaluate((el) => (el as any).CodeMirror?.getValue());
|
||||
|
||||
/**
|
||||
* The App tab of the request pane tab bar.
|
||||
* @param page - The page object
|
||||
*/
|
||||
const requestPaneAppTab = (page: Page) => page.getByTestId('responsive-tab-app');
|
||||
|
||||
/**
|
||||
* Open the request pane's tab overflow dropdown if it is present. Tabs that
|
||||
* don't fit the pane width land there instead of the visible tab row.
|
||||
* @param page - The page object
|
||||
*/
|
||||
const openRequestPaneTabOverflow = async (page: Page) => {
|
||||
const overflowButton = page.locator('[data-testid="request-pane"] .tabs .more-tabs');
|
||||
if (await overflowButton.isVisible()) {
|
||||
await overflowButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A tab entry inside the request pane's overflow dropdown.
|
||||
* @param page - The page object
|
||||
* @param label - The tab label to match (exact match via regex recommended)
|
||||
*/
|
||||
const requestPaneOverflowTabItem = (page: Page, label: string | RegExp) =>
|
||||
page.locator('.tippy-box .dropdown-item').filter({ hasText: label });
|
||||
|
||||
/**
|
||||
* The keep-alive preview slot of the ACTIVE tab. The AppPreviewKeepAlive
|
||||
* overlay keeps app views of background tabs mounted (hidden) and the Electron
|
||||
* instance is shared across tests in a worker, so bare app-view lookups can
|
||||
* match a stale slot from another test. Always scope through the active slot.
|
||||
* @param page - The page object
|
||||
*/
|
||||
const activeAppPreviewSlot = (page: Page) => page.locator('.app-preview-slot.active');
|
||||
|
||||
/**
|
||||
* The app view of the ACTIVE tab (see activeAppPreviewSlot).
|
||||
* @param page - The page object
|
||||
*/
|
||||
const activeAppView = (page: Page) => activeAppPreviewSlot(page).getByTestId('app-view');
|
||||
|
||||
/**
|
||||
* Open the app view via the App tab's "Preview" button. Asserts the app view
|
||||
* takes over the request/response area.
|
||||
* @param page - The page object
|
||||
*/
|
||||
const enableApp = async (page: Page) => {
|
||||
await test.step('Enable app mode (App tab toggle)', async () => {
|
||||
const previewApp = async (page: Page) => {
|
||||
await test.step('Preview app (App tab button)', async () => {
|
||||
await selectRequestPaneTab(page, 'App');
|
||||
await page.getByTestId('app-enable-toggle').click();
|
||||
await expect(page.getByTestId('app-view')).toBeVisible({ timeout: 5000 });
|
||||
await page.getByTestId('app-preview-btn').click();
|
||||
await expect(activeAppView(page)).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2167,8 +2243,8 @@ const enableApp = async (page: Page) => {
|
||||
*/
|
||||
const exitApp = async (page: Page) => {
|
||||
await test.step('Exit app mode', async () => {
|
||||
await page.getByTestId('app-exit-button').click();
|
||||
await expect(page.getByTestId('app-view')).toBeHidden({ timeout: 5000 });
|
||||
await activeAppView(page).getByTestId('app-exit-button').click();
|
||||
await expect(activeAppView(page)).toHaveCount(0, { timeout: 5000 });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2190,7 +2266,7 @@ const selectViewMode = async (page: Page, mode: 'request' | 'app' | 'file') => {
|
||||
* @returns The decoded HTML document string
|
||||
*/
|
||||
const getAppWebviewHtml = async (page: Page): Promise<string> => {
|
||||
const webview = page.getByTestId('app-view').locator('webview');
|
||||
const webview = activeAppView(page).locator('webview');
|
||||
await webview.waitFor({ state: 'attached', timeout: 5000 });
|
||||
const src = await webview.getAttribute('src');
|
||||
if (!src) return '';
|
||||
@@ -2243,7 +2319,10 @@ const createApp = async (
|
||||
*/
|
||||
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();
|
||||
// Scope through the active keep-alive slot — hidden slots of background
|
||||
// app tabs (possibly from earlier tests in the shared Electron) also
|
||||
// render this toggle.
|
||||
await activeAppPreviewSlot(page).getByTestId(`collection-app-view-${view}`).click();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2390,7 +2469,14 @@ export {
|
||||
openFolderSettings,
|
||||
setTableRowDescriptionValue,
|
||||
setAppCode,
|
||||
enableApp,
|
||||
setAppEnabled,
|
||||
readAppEditor,
|
||||
requestPaneAppTab,
|
||||
openRequestPaneTabOverflow,
|
||||
requestPaneOverflowTabItem,
|
||||
activeAppPreviewSlot,
|
||||
activeAppView,
|
||||
previewApp,
|
||||
exitApp,
|
||||
selectViewMode,
|
||||
getAppWebviewHtml,
|
||||
|
||||
32
tests/utils/page/ai.ts
Normal file
32
tests/utils/page/ai.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test, Page } from '../../../playwright';
|
||||
import { openPreferences, selectPreferencesTab } from './preferences';
|
||||
|
||||
export type AiPreferencesSubTab = 'config' | 'autocomplete' | 'security';
|
||||
|
||||
/**
|
||||
* Locators for the AI pane of the Preferences dialog: its sub-tabs
|
||||
* (Config / Autocomplete / Security) and the autocomplete keymap hints.
|
||||
*/
|
||||
export const buildAiPreferencesLocators = (page: Page) => ({
|
||||
/** A sub-tab inside the AI preferences pane */
|
||||
subTab: (key: AiPreferencesSubTab) => page.getByTestId(`ai-tab-${key}`),
|
||||
/** The keyboard-shortcut hint block on the Autocomplete sub-tab */
|
||||
autocompleteKeymap: () => page.locator('.autocomplete-keymap'),
|
||||
/** A single key label inside the autocomplete keymap (exact match) */
|
||||
autocompleteKeymapKey: (label: string) =>
|
||||
page.locator('.autocomplete-keymap').getByText(label, { exact: true })
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the AI pane of the Preferences dialog, optionally landing on a
|
||||
* specific sub-tab.
|
||||
*/
|
||||
export const openAiPreferences = async (page: Page, subTab?: AiPreferencesSubTab) => {
|
||||
await test.step(`Open AI preferences${subTab ? ` ("${subTab}" sub-tab)` : ''}`, async () => {
|
||||
await openPreferences(page);
|
||||
await selectPreferencesTab(page, 'AI');
|
||||
if (subTab) {
|
||||
await buildAiPreferencesLocators(page).subTab(subTab).click();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,4 +2,6 @@ export * from './actions';
|
||||
export * from './runner';
|
||||
export * from './locators';
|
||||
export * from './mounting';
|
||||
export * from './preferences';
|
||||
export * from './ai';
|
||||
export * from '../snapshot';
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Page, Locator } from '../../../playwright';
|
||||
import { buildApiSpecPanelLocators } from './openapi/render-spec';
|
||||
import { buildPreferencesLocators } from './preferences';
|
||||
import { buildAiPreferencesLocators } from './ai';
|
||||
|
||||
export const buildCommonLocators = (page: Page) => ({
|
||||
runner: () => page.getByTestId('run-button'),
|
||||
openApi: {
|
||||
render: buildApiSpecPanelLocators(page)
|
||||
},
|
||||
preferences: buildPreferencesLocators(page),
|
||||
ai: buildAiPreferencesLocators(page),
|
||||
saveButton: () => page
|
||||
.locator('.infotip')
|
||||
.filter({ hasText: /^Save/ }),
|
||||
|
||||
34
tests/utils/page/preferences.ts
Normal file
34
tests/utils/page/preferences.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, Page } from '../../../playwright';
|
||||
|
||||
/**
|
||||
* Locators for the Preferences dialog: the status-bar trigger that opens it
|
||||
* and the top-level tabs inside it (General, Themes, AI, ...).
|
||||
*/
|
||||
export const buildPreferencesLocators = (page: Page) => ({
|
||||
/** Status-bar button that opens the Preferences dialog */
|
||||
statusBarTrigger: () => page.getByRole('button', { name: 'Open Preferences' }),
|
||||
/** A top-level tab inside the Preferences dialog (exact name match) */
|
||||
tab: (name: string) => page.getByRole('tab', { name, exact: true })
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the Preferences dialog via the status bar. Waits for the app to finish
|
||||
* loading first so the status bar is interactive.
|
||||
*/
|
||||
export const openPreferences = async (page: Page) => {
|
||||
await test.step('Open Preferences', async () => {
|
||||
const preferences = buildPreferencesLocators(page);
|
||||
await page.locator('[data-app-state="loaded"]').waitFor();
|
||||
await preferences.statusBarTrigger().click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to a top-level tab inside the (already open) Preferences dialog.
|
||||
*/
|
||||
export const selectPreferencesTab = async (page: Page, name: string) => {
|
||||
await test.step(`Select Preferences tab "${name}"`, async () => {
|
||||
const preferences = buildPreferencesLocators(page);
|
||||
await preferences.tab(name).click();
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user