feat(app): introduce Enable App setting (#8521)

This commit is contained in:
naman-bruno
2026-07-08 21:46:11 +05:30
committed by GitHub
parent 139d71a8f8
commit d0863e2a43
27 changed files with 472 additions and 133 deletions

View File

@@ -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

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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
View 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();
}
});
};

View File

@@ -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';

View File

@@ -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/ }),

View 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();
});
};