From 09b1296316681ac7ce9fc2af6919290052c15879 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Wed, 14 Oct 2026 22:30:37 +0530 Subject: [PATCH 01/73] pin env, response and dock-switch state before the redux refactor --- .../state-survives-navigation.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts new file mode 100644 index 00000000..2d6904d3 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts @@ -0,0 +1,107 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../playwright'; +import type { PlaygroundComponent } from '../../components/playground.component'; +import type { ResponsePaneComponent } from '../../components/playground/response-pane.component'; + +const DEV_HOST = 'https://api.dev.example.com'; +const PROD_HOST = 'https://api.prod.example.com'; + +const mockEndpoint = (page: Page, path: string, body: string) => + page.route(`**/${path}**`, (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body }) + ); + +const sendAndCaptureUrl = async (page: Page, responsePane: ResponsePaneComponent): Promise => { + const [request] = await Promise.all([page.waitForRequest('**/alpha**'), responsePane.send()]); + return request.url(); +}; + +const openPlayground = async (page: Page, playground: PlaygroundComponent) => { + await page.goto('/?fixture=general#/?pg=1&dock=bottom'); + await playground.runner.waitFor({ state: 'visible' }); +}; + +test.describe('Playground state survives navigation inside the page', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test('Try it opens the playground with the environment selected in the docs', async ({ + page, + requestPage, + envSwitcher, + playground, + responsePane + }) => { + await mockEndpoint(page, 'alpha', '{}'); + await page.goto('/?fixture=general'); + await requestPage.sidebar.item('Alpha').click(); + await expect(requestPage.title).toHaveText('Alpha'); + await envSwitcher.selectEnvironment('Prod'); + + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + + await expect(playground.envSwitcher.trigger).toContainText('Prod'); + expect(await sendAndCaptureUrl(page, responsePane)).toMatch(new RegExp(`^${PROD_HOST}/alpha`)); + }); + + test('switching the environment in the playground changes where the request is sent', async ({ + page, + playground, + responsePane + }) => { + await mockEndpoint(page, 'alpha', '{}'); + await openPlayground(page, playground); + await playground.openRequest('Alpha'); + + await playground.envSwitcher.selectEnvironment('Dev'); + expect(await sendAndCaptureUrl(page, responsePane)).toMatch(new RegExp(`^${DEV_HOST}/alpha`)); + + await playground.envSwitcher.selectEnvironment('Prod'); + expect(await sendAndCaptureUrl(page, responsePane)).toMatch(new RegExp(`^${PROD_HOST}/alpha`)); + }); + + test('a response is kept when moving to another request and back, and after close and reopen', async ({ + page, + playground, + responsePane, + requestPage + }) => { + await mockEndpoint(page, 'alpha', '{"from":"alpha"}'); + await openPlayground(page, playground); + await playground.openRequest('Alpha'); + await playground.envSwitcher.selectEnvironment('Dev'); + await responsePane.send(); + await expect(responsePane.status).toBeVisible(); + await expect.poll(() => responsePane.bodyText()).toContain('"from": "alpha"'); + + await playground.openRequest('Beta'); + await expect(responsePane.emptyHint).toBeVisible(); + + await playground.openRequest('Alpha'); + await expect.poll(() => responsePane.bodyText()).toContain('"from": "alpha"'); + + await playground.close(); + await expect(playground.header).toHaveCount(0); + await requestPage.sidebar.item('Alpha').click(); + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + await expect.poll(() => responsePane.bodyText()).toContain('"from": "alpha"'); + }); + + test('a URL edit typed just before a dock switch is kept in every dock', async ({ page, playground }) => { + await openPlayground(page, playground); + await playground.openRequest('Alpha'); + + await playground.urlInput.click(); + await page.keyboard.press('End'); + await page.keyboard.type('&docked=1'); + + await playground.selectDock('inline'); + await expect(playground.inlinePanel).toBeVisible(); + await expect(playground.urlInput).toHaveValue(/&docked=1$/); + + await playground.selectDock('modal'); + await expect(playground.modalPanel).toBeVisible(); + await expect(playground.urlInput).toHaveValue(/&docked=1$/); + }); +}); From b400217c70185577dd8095ea9405cb44f21766b6 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 15 Oct 2026 17:48:58 +0530 Subject: [PATCH 02/73] dropping the unread collection copy --- .../hooks/useVariableResolver.prompt.spec.tsx | 5 ++++- .../src/store/slices/playground.spec.ts | 13 +++---------- .../src/store/slices/playground.ts | 19 ------------------- 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx index 7236e3d2..9d85520c 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx @@ -90,7 +90,10 @@ describe('looking a prompt up through the variable resolver', () => { (box.current as VariableResolver).updateVariable('OTP', 'changed'); - const variables = store.getState().playground.collection?.request?.variables as { value: string }[]; + const variables = store.getState().playground.hydratedCollection?.request?.variables as { + value: string; + }[]; + expect(variables[0].value).toBe('changed'); }); }); diff --git a/packages/bruno-api-docs/src/store/slices/playground.spec.ts b/packages/bruno-api-docs/src/store/slices/playground.spec.ts index c069463e..894cef3c 100644 --- a/packages/bruno-api-docs/src/store/slices/playground.spec.ts +++ b/packages/bruno-api-docs/src/store/slices/playground.spec.ts @@ -4,7 +4,6 @@ import reducer, { updatePlaygroundItem, updatePlaygroundScriptFile, selectHydratedCollection, - selectPlaygroundCollection, setViewMode, setSelectedItemId, setSelectedExampleIndex, @@ -49,8 +48,6 @@ describe('playground edits', () => { store.dispatch(setViewMode('playground')); expect(envVariables(store)).toEqual([{ name: 'a', value: 'edited' }, { name: 'b', value: '2' }]); - expect(selectPlaygroundCollection(store.getState())!.config!.environments![0].variables![0]) - .toMatchObject({ value: 'edited' }); }); }); @@ -78,7 +75,7 @@ describe('updatePlaygroundItem', () => { const firstItem = (store: ReturnType, select: typeof selectHydratedCollection) => select(store.getState())!.items![0] as unknown as { uuid: string; http: { url: string } }; - it('updates the item in the hydrated collection (what the UI renders) as well as the base collection', () => { + it('updates the item in the hydrated collection (what the UI renders)', () => { const store = createOpenCollectionStore(); store.dispatch(setPlaygroundCollection(withRequest())); @@ -88,7 +85,6 @@ describe('updatePlaygroundItem', () => { // The tree the UI reads must reflect the edit, with the uuid preserved so findItemByUuid resolves. expect(firstItem(store, selectHydratedCollection).http.url).toBe('new'); expect(firstItem(store, selectHydratedCollection).uuid).toBe('r1'); - expect(firstItem(store, selectPlaygroundCollection).http.url).toBe('new'); }); }); @@ -102,14 +98,13 @@ describe('updatePlaygroundScriptFile', () => { const scriptSource = (store: ReturnType, select: typeof selectHydratedCollection) => (select(store.getState())!.items![0] as unknown as { script: string }).script; - it('edits the script in both the hydrated and base collections', () => { + it('edits the script in the hydrated collection', () => { const store = createOpenCollectionStore(); store.dispatch(setPlaygroundCollection(withScript())); store.dispatch(updatePlaygroundScriptFile({ uuid: 's1', script: 'const a = 2;' })); expect(scriptSource(store, selectHydratedCollection)).toBe('const a = 2;'); - expect(scriptSource(store, selectPlaygroundCollection)).toBe('const a = 2;'); }); it('restores the original source when the collection is loaded again', () => { @@ -125,15 +120,13 @@ describe('updatePlaygroundScriptFile', () => { }); describe('setPlaygroundVariable', () => { - it('edits an environment variable in both the hydrated and base collections', () => { + it('edits an environment variable in the hydrated collection', () => { const store = createOpenCollectionStore(); store.dispatch(setPlaygroundCollection(makeCollection())); store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'a', value: '99', envName: 'Dev' })); expect((envVariables(store).find((v) => v.name === 'a') as unknown as { value: string }).value).toBe('99'); - const base = selectPlaygroundCollection(store.getState())!.config!.environments![0].variables!; - expect((base.find((v) => v.name === 'a') as unknown as { value: string }).value).toBe('99'); }); it('edits the last enabled duplicate, matching the resolver', () => { diff --git a/packages/bruno-api-docs/src/store/slices/playground.ts b/packages/bruno-api-docs/src/store/slices/playground.ts index f6f624ef..d0eaa20f 100644 --- a/packages/bruno-api-docs/src/store/slices/playground.ts +++ b/packages/bruno-api-docs/src/store/slices/playground.ts @@ -15,7 +15,6 @@ import type { ResponseBodyFormat } from '@/constants'; export type ViewMode = 'playground' | 'environments' | 'folder-settings' | 'collection-settings' | 'example'; export interface PlaygroundState { - collection: OpenCollectionCollection | null; hydratedCollection: OpenCollectionCollection | null; responses: Record; // Store responses by item UUID requestStartedAt: Record; // When each running request started, by item UUID @@ -29,7 +28,6 @@ export interface PlaygroundState { } const initialState: PlaygroundState = { - collection: null, hydratedCollection: null, responses: {}, requestStartedAt: {}, @@ -122,8 +120,6 @@ const playgroundSlice = createSlice({ initialState, reducers: { setPlaygroundCollection: (state: PlaygroundState, action: PayloadAction) => { - state.collection = action.payload; - if (!action.payload) { state.hydratedCollection = null; return; @@ -141,7 +137,6 @@ const playgroundSlice = createSlice({ state.hydratedCollection = hydrated; }, clearPlaygroundCollection: (state: PlaygroundState) => { - state.collection = null; state.hydratedCollection = null; state.responses = {}; state.requestStartedAt = {}; @@ -150,7 +145,6 @@ const playgroundSlice = createSlice({ }, updatePlaygroundItem: (state: PlaygroundState, action: PayloadAction<{ uuid: string; item: HttpRequest }>) => { const { uuid, item } = action.payload; - if (state.collection?.items) findAndUpdateItemInCollection(state.collection.items, uuid, item); if (state.hydratedCollection?.items) findAndUpdateItemInCollection(state.hydratedCollection.items, uuid, item); }, updatePlaygroundScriptFile: (state: PlaygroundState, action: PayloadAction<{ uuid: string; script: string }>) => { @@ -158,7 +152,6 @@ const playgroundSlice = createSlice({ const apply = (item: ScriptFile) => { item.script = script; }; - if (state.collection?.items) findAndUpdateItem(state.collection.items, uuid, apply); if (state.hydratedCollection?.items) findAndUpdateItem(state.hydratedCollection.items, uuid, apply); }, setPlaygroundResponse: (state: PlaygroundState, action: PayloadAction<{ uuid: string; response: any }>) => { @@ -218,11 +211,9 @@ const playgroundSlice = createSlice({ } }, updateCollectionSettings: (state: PlaygroundState, action: PayloadAction) => { - state.collection = action.payload; state.hydratedCollection = action.payload; }, updateCollectionEnvironments: (state: PlaygroundState, action: PayloadAction) => { - state.collection = action.payload; state.hydratedCollection = action.payload; }, applyScriptVariableChanges: ( @@ -253,7 +244,6 @@ const playgroundSlice = createSlice({ } }; - applyTo(state.collection); applyTo(state.hydratedCollection); }, updateFolderInCollection: (state: PlaygroundState, action: PayloadAction<{ uuid: string; folder: Folder }>) => { @@ -263,13 +253,6 @@ const playgroundSlice = createSlice({ findAndUpdateItem(state.hydratedCollection.items, uuid, (item) => { Object.assign(item, folder); }); - - // Also update the base collection - if (state.collection?.items) { - findAndUpdateItem(state.collection.items, uuid, (item) => { - Object.assign(item, folder); - }); - } }, setPlaygroundVariable: ( state: PlaygroundState, @@ -309,7 +292,6 @@ const playgroundSlice = createSlice({ } }; apply(state.hydratedCollection); - apply(state.collection); }, setResponseFormat: (state: PlaygroundState, action: PayloadAction<{ uuid: PlaygroundState['selectedItemId']; @@ -358,7 +340,6 @@ export const { } = playgroundSlice.actions; // Selectors -export const selectPlaygroundCollection = (state: RootState) => state.playground.collection; export const selectHydratedCollection = (state: RootState) => state.playground.hydratedCollection; export const selectPlaygroundResponses = (state: RootState) => state.playground.responses; export const selectPlaygroundResponse = (state: RootState, uuid: string) => state.playground.responses[uuid]; From 9c0e5fdbe448b19c7fe383255a0849b3f8ac0a89 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Fri, 16 Oct 2026 12:42:04 +0530 Subject: [PATCH 03/73] add tests for theme toggle functionality in components --- .../theming/theme-follows-components.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts b/packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts new file mode 100644 index 00000000..6c5459f5 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../playwright'; + +const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}'; + +test.describe('Components that cannot read CSS variables follow the theme toggle', () => { + test.use({ colorScheme: 'light', viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ playground, responsePane }) => { + await responsePane.mockUsersResponse(USERS_BODY); + await playground.open('bottom'); + await playground.openRequest('get users'); + await responsePane.send(); + await expect(responsePane.status).toBeVisible(); + }); + + test('the code editor switches between its light and dark theme', async ({ themeToggle, responsePane }) => { + await expect(responsePane.bodyEditorCanvas).toHaveClass(/\bvs\b/); + + await themeToggle.toggle(); + await expect(responsePane.bodyEditorCanvas).toHaveClass(/\bvs-dark\b/); + + await themeToggle.toggle(); + await expect(responsePane.bodyEditorCanvas).toHaveClass(/\bvs\b/); + }); + + test('the JSON preview switches between its light and dark theme', async ({ themeToggle, responsePane }) => { + await responsePane.openFormatSelector(); + await responsePane.togglePreview(); + await expect(responsePane.jsonPreview).toBeVisible(); + const icon = responsePane.jsonPreview.locator('.expanded-icon svg').first(); + const colour = () => icon.evaluate((el) => getComputedStyle(el).color); + + const light = await colour(); + await themeToggle.toggle(); + await expect.poll(colour).not.toBe(light); + + await themeToggle.toggle(); + await expect.poll(colour).toBe(light); + }); +}); From 6e8b735439b58ac2722e55f4d3046b9f83b2d809 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Sat, 24 Oct 2026 21:23:28 +0530 Subject: [PATCH 04/73] adds theme obj --- packages/bruno-api-docs/src/theme/themeMode.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/bruno-api-docs/src/theme/themeMode.ts diff --git a/packages/bruno-api-docs/src/theme/themeMode.ts b/packages/bruno-api-docs/src/theme/themeMode.ts new file mode 100644 index 00000000..3ca36c77 --- /dev/null +++ b/packages/bruno-api-docs/src/theme/themeMode.ts @@ -0,0 +1,10 @@ +import type { ThemeMode } from './types'; + +export const themeMode = { + get: () => null, + set: (themeMode: ThemeMode) => themeMode, + toggle: () => null, + subscribe: () => null, + apply: () => null + +}; From 834481229d1c5fad1dddbeac62abbabe0c1ee611 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:11:44 +0530 Subject: [PATCH 05/73] pin standalone bundle lifecycle in e2e --- packages/bruno-api-docs/e2e/README.md | 4 ++ .../bruno-api-docs/e2e/config/app.config.ts | 5 +- .../e2e/pages/standalone.page.ts | 68 +++++++++++++++++++ .../e2e/playwright/pages.fixture.ts | 5 ++ .../e2e/server/standalone/index.html | 17 +++++ .../bruno-api-docs/e2e/server/test-server.mjs | 17 +++++ .../standalone/standalone-lifecycle.spec.ts | 55 +++++++++++++++ packages/bruno-api-docs/playwright.config.ts | 7 ++ 8 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/bruno-api-docs/e2e/pages/standalone.page.ts create mode 100644 packages/bruno-api-docs/e2e/server/standalone/index.html create mode 100644 packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts diff --git a/packages/bruno-api-docs/e2e/README.md b/packages/bruno-api-docs/e2e/README.md index 5b3288dd..3802d9e2 100644 --- a/packages/bruno-api-docs/e2e/README.md +++ b/packages/bruno-api-docs/e2e/README.md @@ -47,6 +47,10 @@ playground sends to it. | `/echo` | 200, JSON of the method, path, query, headers and body it received | | `/cors/deny` | 200 with no CORS headers, so the browser blocks it | | `/health` | 200 `ok`, used by Playwright to know the server is up | +| `/` | a file from `server/standalone/` or the built `dist-standalone/` | + +A second copy runs on `standaloneURL` (port 3901) after building the standalone bundle, so +`tests/standalone/` can load `index.html` and drive `Bruno.apiDocs` like an embedding site. Every route except `/cors/deny` answers CORS and preflight requests. Use it for what the playground sends over the network; keep `page.route` stubs for tests about how a response diff --git a/packages/bruno-api-docs/e2e/config/app.config.ts b/packages/bruno-api-docs/e2e/config/app.config.ts index c72463de..c68feffb 100644 --- a/packages/bruno-api-docs/e2e/config/app.config.ts +++ b/packages/bruno-api-docs/e2e/config/app.config.ts @@ -5,10 +5,13 @@ export interface AppConfig { webServerCommand: string; /** Origin of the e2e test server (`e2e/server/test-server.mjs`), separate from the app so CORS applies. */ testServerURL: string; + /** Serves the built standalone bundle and `e2e/server/standalone/` (same test server, own port). */ + standaloneURL: string; } export const appConfig: AppConfig = { baseURL: 'http://127.0.0.1:3001', webServerCommand: 'npm run dev', - testServerURL: 'http://127.0.0.1:3900' + testServerURL: 'http://127.0.0.1:3900', + standaloneURL: 'http://127.0.0.1:3901' }; diff --git a/packages/bruno-api-docs/e2e/pages/standalone.page.ts b/packages/bruno-api-docs/e2e/pages/standalone.page.ts new file mode 100644 index 00000000..444f0cc8 --- /dev/null +++ b/packages/bruno-api-docs/e2e/pages/standalone.page.ts @@ -0,0 +1,68 @@ +import type { Locator } from '@playwright/test'; +import { BasePage } from './base.page'; +import { appConfig } from '../config/app.config'; + +export type StandaloneTarget = 'first' | 'second'; + +type ApiDocsOptions = Record & { content: unknown }; + +interface Instance { + updateCollection(content: unknown): void; + updateOptions(options: Record): void; + destroy(): void; +} + +type StandaloneWindow = Window & { + Bruno: { apiDocs(target: HTMLElement, options: ApiDocsOptions): Instance }; + instances: Partial>; +}; + +/** + * A static host page that loads the built `dist-standalone/api-docs.js` and mounts it + * with `Bruno.apiDocs`, the way an embedding site does. Two empty targets are on the page. + */ +export class StandalonePage extends BasePage { + readonly root = this.target('first'); + + target(id: StandaloneTarget): Locator { + return this.page.getByTestId(`standalone-${id}`); + } + + async goto(): Promise { + await this.navigate(`${appConfig.standaloneURL}/index.html`); + } + + async mount(id: StandaloneTarget, options: ApiDocsOptions): Promise { + await this.page.evaluate(({ id, options }) => { + const w = window as unknown as StandaloneWindow; + w.instances ??= {}; + w.instances[id] = w.Bruno.apiDocs(document.getElementById(id)!, options); + }, { id, options }); + } + + async mountFile(id: StandaloneTarget, text: string, name: string): Promise { + await this.page.evaluate(({ id, text, name }) => { + const w = window as unknown as StandaloneWindow; + w.instances ??= {}; + w.instances[id] = w.Bruno.apiDocs(document.getElementById(id)!, { content: new File([text], name) }); + }, { id, text, name }); + } + + async updateCollection(id: StandaloneTarget, content: unknown): Promise { + await this.page.evaluate(({ id, content }) => { + (window as unknown as StandaloneWindow).instances[id]!.updateCollection(content); + }, { id, content }); + } + + async updateOptions(id: StandaloneTarget, options: Record): Promise { + await this.page.evaluate(({ id, options }) => { + (window as unknown as StandaloneWindow).instances[id]!.updateOptions(options); + }, { id, options }); + } + + async destroy(id: StandaloneTarget): Promise { + await this.page.evaluate((id) => { + (window as unknown as StandaloneWindow).instances[id]!.destroy(); + }, id); + } +} diff --git a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts index 37f45326..ee4ef895 100644 --- a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts +++ b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts @@ -7,6 +7,7 @@ import { ScriptPage } from '../pages/script.page'; import { FolderPage } from '../pages/folder.page'; import { GrpcRequestPage } from '../pages/grpc-request.page'; import { WebsocketRequestPage } from '../pages/websocket-request.page'; +import { StandalonePage } from '../pages/standalone.page'; import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; @@ -29,6 +30,7 @@ type Fixtures = { folderPage: FolderPage; grpcRequestPage: GrpcRequestPage; websocketRequestPage: WebsocketRequestPage; + standalonePage: StandalonePage; sidebar: SidebarComponent; tooltip: TooltipComponent; playground: PlaygroundComponent; @@ -68,6 +70,9 @@ export const test = base.extend({ websocketRequestPage: async ({ page }, use) => { await use(new WebsocketRequestPage(page)); }, + standalonePage: async ({ page }, use) => { + await use(new StandalonePage(page)); + }, sidebar: async ({ page }, use) => { await use(new SidebarComponent(page)); }, diff --git a/packages/bruno-api-docs/e2e/server/standalone/index.html b/packages/bruno-api-docs/e2e/server/standalone/index.html new file mode 100644 index 00000000..e8e61209 --- /dev/null +++ b/packages/bruno-api-docs/e2e/server/standalone/index.html @@ -0,0 +1,17 @@ + + + + + Standalone bundle + + + + + +
+
+ + diff --git a/packages/bruno-api-docs/e2e/server/test-server.mjs b/packages/bruno-api-docs/e2e/server/test-server.mjs index 9176a208..cc30b165 100644 --- a/packages/bruno-api-docs/e2e/server/test-server.mjs +++ b/packages/bruno-api-docs/e2e/server/test-server.mjs @@ -1,4 +1,6 @@ import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; const port = Number(process.env.PORT ?? 3900); @@ -10,6 +12,18 @@ const corsHeaders = (req) => ({ 'Vary': 'Origin' }); +const staticRoots = [new URL('./standalone/', import.meta.url), new URL('../../dist-standalone/', import.meta.url)]; +const contentTypes = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }; + +const readStatic = async (name) => { + if (!/^[\w.-]+$/.test(name)) return null; + for (const root of staticRoots) { + const body = await readFile(new URL(name, root)).catch(() => null); + if (body) return body; + } + return null; +}; + const readBody = async (req) => { const chunks = []; for await (const chunk of req) chunks.push(chunk); @@ -49,6 +63,9 @@ const handle = async (req, res) => { return send(res, 200, { ...cors, 'Content-Type': 'application/json' }, JSON.stringify(echoed)); } + const file = await readStatic(url.pathname.slice(1)); + if (file) return send(res, 200, { ...cors, 'Content-Type': contentTypes[extname(url.pathname)] ?? 'text/plain' }, file); + send(res, 404, { ...cors, 'Content-Type': 'text/plain' }, 'not found'); }; diff --git a/packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts new file mode 100644 index 00000000..786a0418 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection, generalOneEnvCollection } from '../../../src/e2eFixtures/generalCollection'; + +test.use({ colorScheme: 'light' }); + +test.describe('Standalone bundle lifecycle', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + }); + + test('Bruno.apiDocs renders the collection into the target', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: generalFixtureCollection }); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.item('Alpha')).toBeVisible(); + await expect(sidebar.item('Beta')).toBeVisible(); + await expect(standalonePage.target('second')).toBeEmpty(); + }); + + test('updateCollection swaps the rendered collection in place', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: generalFixtureCollection }); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + + await standalonePage.updateCollection('first', generalOneEnvCollection); + + await expect(pageHeader.brandName).toHaveText('Single Environment'); + await expect(sidebar.item('Ping')).toBeVisible(); + await expect(sidebar.item('Alpha')).toHaveCount(0); + }); + + test('updateOptions applies a new theme to the mounted docs', async ({ standalonePage, themeToggle }) => { + await standalonePage.mount('first', { content: generalFixtureCollection }); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-light'); + + await standalonePage.updateOptions('first', { theme: { mode: 'dark' } }); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + await expect(themeToggle.button).toHaveAccessibleName('Switch to light theme'); + }); + + test('destroy empties the target and leaves the page theme attribute as it was', async ({ + standalonePage, + pageHeader, + themeToggle + }) => { + await standalonePage.mount('first', { content: generalFixtureCollection, theme: { mode: 'dark' } }); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + + await standalonePage.destroy('first'); + + await expect(standalonePage.target('first')).toBeEmpty(); + await expect(pageHeader.root).toHaveCount(0); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + }); +}); diff --git a/packages/bruno-api-docs/playwright.config.ts b/packages/bruno-api-docs/playwright.config.ts index 3ad373a3..28a811b7 100644 --- a/packages/bruno-api-docs/playwright.config.ts +++ b/packages/bruno-api-docs/playwright.config.ts @@ -35,5 +35,12 @@ export default defineConfig({ env: { PORT: new URL(appConfig.testServerURL).port }, reuseExistingServer: !process.env.CI, }, + { + command: 'npm run build:lib-bundle && npm run build:standalone && node e2e/server/test-server.mjs', + url: `${appConfig.standaloneURL}/health`, + env: { PORT: new URL(appConfig.standaloneURL).port }, + reuseExistingServer: !process.env.CI, + timeout: 300_000, + }, ], }); From a56612ec3ed738de2f0c6401d93fdc7110c3814d Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:20:22 +0530 Subject: [PATCH 06/73] pin what updateCollection does to playground edits, env and navigation --- .../update-collection-after-edits.spec.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts new file mode 100644 index 00000000..76688e66 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const ALPHA_URL = '{{host}}/alpha?q={{special}}&m={{onlyInDev}}&t={{apiToken}}'; +const EDITED_URL = 'https://edited.example.com/alpha'; + +test.describe('updateCollection after playground edits', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ standalonePage, requestPage, envSwitcher, playground }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await requestPage.sidebar.item('Alpha').click(); + await envSwitcher.selectEnvironment('Prod'); + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + await playground.urlInput.fill(EDITED_URL); + }); + + test('the same collection again empties the open playground request but keeps the URL, docs page and environment', async ({ + page, + standalonePage, + requestPage, + envSwitcher, + playground + }) => { + await expect(playground.urlInput).toHaveValue(EDITED_URL); + + await standalonePage.updateCollection('first', generalFixtureCollection); + + await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); + await expect(playground.urlInput).toHaveCount(0); + expect(new URL(page.url()).hash).toBe('#/alpha?pg=1&dock=bottom&pgReq=alpha'); + await expect(requestPage.title).toHaveText('Alpha'); + await expect(envSwitcher.trigger).toHaveText('Prod'); + await expect(playground.envSwitcher.trigger).toHaveText('Prod'); + }); + + test('the edit is dropped: reopening the request shows the collection\'s URL', async ({ + standalonePage, + playground + }) => { + await expect(playground.urlInput).toHaveValue(EDITED_URL); + + await standalonePage.updateCollection('first', generalFixtureCollection); + await playground.openRequest('Alpha'); + + await expect(playground.urlInput).toHaveValue(ALPHA_URL); + }); +}); From 89d734a1929fd0d6e47de148a320c7dd05987419 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:22:39 +0530 Subject: [PATCH 07/73] pin docs nav and playground when a different collection is passed while mounted --- .../e2e/pages/standalone.page.ts | 8 +- .../collection-change-while-mounted.spec.ts | 97 +++++++++++++++++++ .../update-collection-after-edits.spec.ts | 2 +- 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts diff --git a/packages/bruno-api-docs/e2e/pages/standalone.page.ts b/packages/bruno-api-docs/e2e/pages/standalone.page.ts index 444f0cc8..f1ccbc56 100644 --- a/packages/bruno-api-docs/e2e/pages/standalone.page.ts +++ b/packages/bruno-api-docs/e2e/pages/standalone.page.ts @@ -28,8 +28,12 @@ export class StandalonePage extends BasePage { return this.page.getByTestId(`standalone-${id}`); } - async goto(): Promise { - await this.navigate(`${appConfig.standaloneURL}/index.html`); + url(hash = ''): string { + return `${appConfig.standaloneURL}/index.html${hash}`; + } + + async goto(hash = ''): Promise { + await this.navigate(this.url(hash)); } async mount(id: StandaloneTarget, options: ApiDocsOptions): Promise { diff --git a/packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts new file mode 100644 index 00000000..ded5ae30 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection, generalOneEnvCollection } from '../../../src/e2eFixtures/generalCollection'; + +const renamedGeneral = { ...generalFixtureCollection, info: { name: 'Renamed', version: '2.0.0' } }; + +test.describe('A different collection passed while mounted', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.describe('with the docs on a request page', () => { + test.beforeEach(async ({ standalonePage, sidebar, envSwitcher }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await sidebar.item('Beta').click(); + await envSwitcher.selectEnvironment('Prod'); + }); + + test('a collection that still has the page keeps the page and environment and shows the new name', async ({ + page, + standalonePage, + pageHeader, + requestPage, + sidebar, + envSwitcher + }) => { + await standalonePage.updateCollection('first', renamedGeneral); + + await expect(pageHeader.brandName).toHaveText('Renamed'); + await expect(requestPage.title).toHaveText('Beta'); + await expect(sidebar.active).toHaveText('GETBeta'); + await expect(envSwitcher.trigger).toHaveText('Prod'); + await expect(page).toHaveURL(standalonePage.url('#/beta')); + }); + + test('a collection without the page sends the docs to the overview and picks its first environment', async ({ + page, + standalonePage, + overviewPage, + sidebar, + envSwitcher + }) => { + await standalonePage.updateCollection('first', generalOneEnvCollection); + + await expect(overviewPage.root).toBeVisible(); + await expect(sidebar.items).toHaveText(['GETPing']); + await expect(envSwitcher.trigger).toHaveText('Staging'); + await expect(page).toHaveURL(standalonePage.url('#/')); + expect(await page.evaluate(() => sessionStorage.getItem('oc-docs.env'))) + .toBe(JSON.stringify({ activeEnvName: 'Staging', showVars: false })); + }); + + test('going back to the first collection does not bring back its earlier environment', async ({ + standalonePage, + pageHeader, + envSwitcher + }) => { + await standalonePage.updateCollection('first', generalOneEnvCollection); + await expect(envSwitcher.trigger).toHaveText('Staging'); + + await standalonePage.updateCollection('first', generalFixtureCollection); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(envSwitcher.trigger).toHaveText('Dev'); + }); + }); + + test.describe('with the playground open on a request', () => { + test.beforeEach(async ({ standalonePage, playground }) => { + await standalonePage.goto('#/?pg=1&dock=bottom'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await playground.openRequest('Beta'); + }); + + test('the playground stays open with no request selected, its URL unchanged', async ({ + page, + standalonePage, + playground + }) => { + await expect(playground.urlInput).toHaveValue('{{host}}/beta'); + + await standalonePage.updateCollection('first', renamedGeneral); + + await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); + await expect(playground.treeItems).toHaveText(['GETAlpha', 'GETBeta']); + await expect(page).toHaveURL(standalonePage.url('#/?pg=1&dock=bottom&pgReq=beta')); + }); + + test('the playground tree lists the new collection\'s requests', async ({ page, standalonePage, playground }) => { + await expect(playground.urlInput).toHaveValue('{{host}}/beta'); + + await standalonePage.updateCollection('first', generalOneEnvCollection); + + await expect(playground.treeItems).toHaveText(['GETPing']); + await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); + await expect(page).toHaveURL(standalonePage.url('#/?pg=1&dock=bottom&pgReq=beta')); + }); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts index 76688e66..6738d8de 100644 --- a/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts @@ -30,7 +30,7 @@ test.describe('updateCollection after playground edits', () => { await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); await expect(playground.urlInput).toHaveCount(0); - expect(new URL(page.url()).hash).toBe('#/alpha?pg=1&dock=bottom&pgReq=alpha'); + await expect(page).toHaveURL(standalonePage.url('#/alpha?pg=1&dock=bottom&pgReq=alpha')); await expect(requestPage.title).toHaveText('Alpha'); await expect(envSwitcher.trigger).toHaveText('Prod'); await expect(playground.envSwitcher.trigger).toHaveText('Prod'); From fdf60bfeab1311eeb1dca8c3d3edc37047c83c32 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:24:00 +0530 Subject: [PATCH 08/73] pin string, file and url collection inputs with loading and error states --- .../standalone/collection-inputs.spec.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts new file mode 100644 index 00000000..55f27da3 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts @@ -0,0 +1,90 @@ +import { dump } from 'js-yaml'; +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const YAML = dump(generalFixtureCollection); +const BROKEN_YAML = 'key: [unclosed'; +const COLLECTION_URL = 'https://collections.example.com/general.yml'; + +test.describe('Collection inputs accepted by Bruno.apiDocs', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + }); + + test('a YAML string renders the collection', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: YAML }); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('a JSON string renders the collection', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: JSON.stringify(generalFixtureCollection) }); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('an unparseable string throws from Bruno.apiDocs and leaves the target empty', async ({ standalonePage }) => { + await expect(standalonePage.mount('first', { content: BROKEN_YAML })) + .rejects.toThrow('Invalid collection format: Failed to parse YAML: unexpected end of the stream within a flow collection'); + + await expect(standalonePage.target('first')).toBeEmpty(); + }); + + test('a string that parses to a plain YAML scalar renders an empty untitled collection', async ({ + standalonePage, + pageHeader, + overviewPage, + sidebar + }) => { + await standalonePage.mount('first', { content: 'just words' }); + + await expect(pageHeader.brandName).toHaveText('API Collection'); + await expect(overviewPage.header.collectionName).toHaveText('Untitled Collection'); + await expect(sidebar.items).toHaveCount(0); + }); + + test('a File renders the collection', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mountFile('first', YAML, 'general.yml'); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('an unparseable File shows the parse error in the target', async ({ standalonePage }) => { + await standalonePage.mountFile('first', BROKEN_YAML, 'broken.yml'); + + await expect(standalonePage.target('first')).toHaveText('Error: Failed to parse collection as YAML or JSON'); + }); + + test('a URL string shows Loading while the collection is fetched, then renders it', async ({ + page, + standalonePage, + pageHeader, + sidebar + }) => { + let respond!: () => void; + const responded = new Promise((resolve) => { respond = resolve; }); + await page.route(COLLECTION_URL, async (route) => { + await responded; + await route.fulfill({ status: 200, body: YAML }); + }); + + await standalonePage.mount('first', { content: COLLECTION_URL }); + await expect(standalonePage.target('first')).toHaveText('Loading...'); + + respond(); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('a URL that answers 404 shows the fetch error in the target', async ({ page, standalonePage }) => { + await page.route(COLLECTION_URL, (route) => route.fulfill({ status: 404, body: 'nope' })); + + await standalonePage.mount('first', { content: COLLECTION_URL }); + + await expect(standalonePage.target('first')).toHaveText('Error: Failed to fetch collection: Not Found'); + }); +}); From 6b6fd5eb5a99656c9bfc64c82441c23468eb0a82 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:27:36 +0530 Subject: [PATCH 09/73] pin theme, hideThemeSwitcher and gitCollectionUrl changed through updateOptions --- .../tests/standalone/update-options.spec.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts new file mode 100644 index 00000000..68dfff55 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const THEME_STORAGE_KEY = 'oc-docs.theme'; +const SAVED_DARK = JSON.stringify({ variant: 'default', mode: 'dark' }); +const GIT_URL = 'https://github.com/usebruno/bruno-testbench.git'; +const FETCH_IN_BRUNO_HREF = `https://fetch.usebruno.com?url=${encodeURIComponent(GIT_URL)}`; + +test.use({ colorScheme: 'light' }); + +test.describe('updateOptions after mount', () => { + test.describe('theme and hideThemeSwitcher, with a saved dark choice', () => { + test.beforeEach(async ({ page, standalonePage }) => { + await page.addInitScript(({ key, value }) => { + localStorage.setItem(key, value); + }, { key: THEME_STORAGE_KEY, value: SAVED_DARK }); + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + }); + + test('a host theme passed later applies its variant but not its mode over the saved choice', async ({ + standalonePage, + themeToggle + }) => { + await standalonePage.updateOptions('first', { theme: { variant: 'backstage', mode: 'light' } }); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'backstage-dark'); + await expect(themeToggle.button).toHaveAccessibleName('Switch to light theme'); + }); + + test('hiding the switcher later removes the toggle and follows the OS, ignoring the saved choice', async ({ + page, + standalonePage, + themeToggle, + pageHeader + }) => { + await standalonePage.updateOptions('first', { hideThemeSwitcher: true }); + + await expect(themeToggle.button).toHaveCount(0); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-light'); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + expect(await page.evaluate((key) => localStorage.getItem(key), THEME_STORAGE_KEY)).toBe(SAVED_DARK); + }); + + test('showing the switcher again brings back the toggle and the saved choice', async ({ standalonePage, themeToggle }) => { + await standalonePage.updateOptions('first', { hideThemeSwitcher: true }); + await expect(themeToggle.button).toHaveCount(0); + + await standalonePage.updateOptions('first', { hideThemeSwitcher: false }); + + await expect(themeToggle.button).toHaveAccessibleName('Switch to light theme'); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + }); + }); + + test.describe('gitCollectionUrl', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + }); + + test('setting it later turns Open in Bruno into a Fetch-in-Bruno link', async ({ standalonePage, pageHeader }) => { + await expect(pageHeader.openInBruno).toHaveAttribute('type', 'button'); + + await standalonePage.updateOptions('first', { gitCollectionUrl: GIT_URL }); + + await expect(pageHeader.openInBruno).toHaveAttribute('href', FETCH_IN_BRUNO_HREF); + await expect(pageHeader.openInBruno).toHaveAttribute('target', '_blank'); + }); + + test('clearing it later turns Open in Bruno back into a button', async ({ standalonePage, pageHeader }) => { + await standalonePage.updateOptions('first', { gitCollectionUrl: GIT_URL }); + await expect(pageHeader.openInBruno).toHaveAttribute('href', FETCH_IN_BRUNO_HREF); + + await standalonePage.updateOptions('first', { gitCollectionUrl: null }); + + await expect(pageHeader.openInBruno).toHaveAttribute('type', 'button'); + await expect(pageHeader.openInBruno).not.toHaveAttribute('href', /.*/); + }); + + test('repositoryUrl, accepted by Bruno.apiDocs, is ignored by updateOptions', async ({ + standalonePage, + pageHeader, + themeToggle + }) => { + await standalonePage.updateOptions('first', { repositoryUrl: GIT_URL, theme: { variant: 'backstage' } }); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'backstage-light'); + await expect(pageHeader.openInBruno).toHaveAttribute('type', 'button'); + await expect(pageHeader.openInBruno).not.toHaveAttribute('href', /.*/); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + }); + }); +}); From b89342d0e221061cb73dfa86cdf505a99cc01e95 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:29:01 +0530 Subject: [PATCH 10/73] pin selected environment surviving a reload in the dev app --- .../environments/env-survives-reload.spec.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts b/packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts new file mode 100644 index 00000000..6a695f77 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../../playwright'; +import { EnvSwitcherComponent } from '../../components/layout/env-switcher.component'; + +const ENV_STORAGE_KEY = 'oc-docs.env'; + +test.describe('The selected environment survives a reload in the same tab', () => { + test.beforeEach(async ({ page, envSwitcher }) => { + await page.goto('/?fixture=general'); + await envSwitcher.selectEnvironment('Prod'); + }); + + test('the docs switcher shows the chosen environment after a reload', async ({ page, envSwitcher }) => { + await expect(envSwitcher.trigger).toHaveText('Prod'); + + await page.reload(); + + await expect(envSwitcher.trigger).toHaveText('Prod'); + expect(await page.evaluate((key) => sessionStorage.getItem(key), ENV_STORAGE_KEY)) + .toBe(JSON.stringify({ activeEnvName: 'Prod', showVars: false })); + }); + + test('show vars is kept across a reload with the environment', async ({ page, envSwitcher }) => { + await envSwitcher.toggle(); + await expect(envSwitcher.showVarsToggle).toHaveAttribute('aria-checked', 'true'); + + await page.reload(); + + await expect(envSwitcher.showVarsToggle).toHaveAttribute('aria-checked', 'true'); + await expect(envSwitcher.trigger).toHaveText('Prod'); + }); + + test('the playground opens on the chosen environment after a reload', async ({ page, playground }) => { + await page.goto('/?fixture=general#/?pg=1&dock=bottom'); + await page.reload(); + await playground.runner.waitFor({ state: 'visible' }); + await playground.ensureSidebarOpen(); + + await expect(playground.envSwitcher.trigger).toHaveText('Prod'); + }); + + test('a new tab starts on the first environment', async ({ context, envSwitcher }) => { + await expect(envSwitcher.trigger).toHaveText('Prod'); + + const fresh = await context.newPage(); + await fresh.goto('/?fixture=general'); + + await expect(new EnvSwitcherComponent(fresh).trigger).toHaveText('Dev'); + }); +}); From c8f7dcc216961c5569cba31ed1f30d5ba0ebcbd4 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:30:17 +0530 Subject: [PATCH 11/73] pin how two standalone instances on one page share theme, env, url and responses --- .../tests/standalone/two-instances.spec.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts diff --git a/packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts new file mode 100644 index 00000000..a440981c --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const secondCollection = { ...generalFixtureCollection, info: { name: 'Second', version: '1.0.0' } }; + +test.use({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' }); + +test.describe('Two Bruno.apiDocs instances on one page', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await standalonePage.mount('second', { content: secondCollection }); + }); + + test('switching the environment in one leaves the other on its own', async ({ page, standalonePage, envSwitcher }) => { + const first = standalonePage.target('first'); + const second = standalonePage.target('second'); + await expect(second.getByTestId('brand-name')).toHaveText('Second'); + + await first.getByTestId('env-switcher').click(); + await envSwitcher.option('Prod').click(); + + await expect(first.getByTestId('env-switcher')).toHaveText('Prod'); + await expect(second.getByTestId('env-switcher')).toHaveText('Dev'); + expect(await page.evaluate(() => sessionStorage.getItem('oc-docs.env'))) + .toBe(JSON.stringify({ activeEnvName: 'Prod', showVars: false })); + }); + + test('toggling the theme in one darkens the whole page but only flips that instance\'s toggle', async ({ + standalonePage, + themeToggle + }) => { + const first = standalonePage.target('first'); + const second = standalonePage.target('second'); + + await first.getByTestId('theme-toggle').click(); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + await expect(first.getByTestId('theme-toggle')).toHaveAccessibleName('Switch to light theme'); + await expect(second.getByTestId('theme-toggle')).toHaveAccessibleName('Switch to dark theme'); + }); + + test('a light theme passed to the other afterwards keeps the page dark and flips its toggle to the saved choice', async ({ + standalonePage, + themeToggle + }) => { + const first = standalonePage.target('first'); + const second = standalonePage.target('second'); + await first.getByTestId('theme-toggle').click(); + await expect(second.getByTestId('theme-toggle')).toHaveAccessibleName('Switch to dark theme'); + + await standalonePage.updateOptions('second', { theme: { mode: 'light' } }); + + await expect(second.getByTestId('theme-toggle')).toHaveAccessibleName('Switch to light theme'); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + }); + + test('navigating in one changes the shared URL hash but only that instance\'s page', async ({ page, standalonePage }) => { + const first = standalonePage.target('first'); + const second = standalonePage.target('second'); + + await first.getByTestId('sidebar-item').filter({ hasText: 'Alpha' }).click(); + + await expect(first.getByTestId('request-title')).toHaveText('Alpha'); + await expect(page).toHaveURL(standalonePage.url('#/alpha')); + await expect(second.getByTestId('overview')).toBeVisible(); + await expect(second.getByTestId('request-title')).toHaveCount(0); + }); + + test('a request sent from one playground shows its response only in that instance', async ({ page, standalonePage }) => { + const first = standalonePage.target('first'); + const second = standalonePage.target('second'); + await page.route('**/alpha**', (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":1}' }) + ); + await first.getByTestId('sidebar-item').filter({ hasText: 'Alpha' }).click(); + await first.getByTestId('request-try-button').click(); + await expect(first.getByTestId('playground-view')).toBeVisible(); + + await first.getByTestId('query-bar-send').click(); + + await expect(first.getByTestId('response-status')).toHaveText('200 OK'); + await expect(second.getByTestId('playground-view')).toHaveCount(0); + await expect(second.getByTestId('response-status')).toHaveCount(0); + await expect(page).toHaveURL(standalonePage.url('#/alpha?pg=1&dock=bottom&pgReq=alpha')); + }); +}); From 0bd111ab01b4406cde3b0616eab10d65a56ebb86 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:35:13 +0530 Subject: [PATCH 12/73] run standalone e2e under its own config so other specs skip the bundle build --- packages/bruno-api-docs/e2e/README.md | 6 +++-- packages/bruno-api-docs/package.json | 3 ++- packages/bruno-api-docs/playwright.config.ts | 8 +----- .../playwright.standalone.config.ts | 26 +++++++++++++++++++ 4 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 packages/bruno-api-docs/playwright.standalone.config.ts diff --git a/packages/bruno-api-docs/e2e/README.md b/packages/bruno-api-docs/e2e/README.md index 3802d9e2..6ac80fde 100644 --- a/packages/bruno-api-docs/e2e/README.md +++ b/packages/bruno-api-docs/e2e/README.md @@ -49,8 +49,10 @@ playground sends to it. | `/health` | 200 `ok`, used by Playwright to know the server is up | | `/` | a file from `server/standalone/` or the built `dist-standalone/` | -A second copy runs on `standaloneURL` (port 3901) after building the standalone bundle, so -`tests/standalone/` can load `index.html` and drive `Bruno.apiDocs` like an embedding site. +`tests/standalone/` runs under its own config (`npm run test:e2e:standalone`), which builds the +standalone bundle and starts a second copy on `standaloneURL` (port 3901), so specs can load +`index.html` and drive `Bruno.apiDocs` like an embedding site. The main config skips that folder, +so other specs don't pay for the build. `npm run test:e2e` runs both. Every route except `/cors/deny` answers CORS and preflight requests. Use it for what the playground sends over the network; keep `page.route` stubs for tests about how a response diff --git a/packages/bruno-api-docs/package.json b/packages/bruno-api-docs/package.json index 8cc0213b..2ea6b61b 100644 --- a/packages/bruno-api-docs/package.json +++ b/packages/bruno-api-docs/package.json @@ -50,7 +50,8 @@ "test": "vitest", "pretest:run": "npm run build:lib-bundle", "test:run": "vitest run", - "test:e2e": "playwright test", + "test:e2e": "playwright test && playwright test -c playwright.standalone.config.ts", + "test:e2e:standalone": "playwright test -c playwright.standalone.config.ts", "test:e2e:ui": "playwright test --ui", "clean": "rimraf dist", "clean:standalone": "rimraf dist-standalone" diff --git a/packages/bruno-api-docs/playwright.config.ts b/packages/bruno-api-docs/playwright.config.ts index 28a811b7..4294f28b 100644 --- a/packages/bruno-api-docs/playwright.config.ts +++ b/packages/bruno-api-docs/playwright.config.ts @@ -3,6 +3,7 @@ import { appConfig } from './e2e/config/app.config'; export default defineConfig({ testDir: './e2e', + testIgnore: 'tests/standalone/**', tsconfig: './e2e/tsconfig.json', fullyParallel: true, forbidOnly: !!process.env.CI, @@ -35,12 +36,5 @@ export default defineConfig({ env: { PORT: new URL(appConfig.testServerURL).port }, reuseExistingServer: !process.env.CI, }, - { - command: 'npm run build:lib-bundle && npm run build:standalone && node e2e/server/test-server.mjs', - url: `${appConfig.standaloneURL}/health`, - env: { PORT: new URL(appConfig.standaloneURL).port }, - reuseExistingServer: !process.env.CI, - timeout: 300_000, - }, ], }); diff --git a/packages/bruno-api-docs/playwright.standalone.config.ts b/packages/bruno-api-docs/playwright.standalone.config.ts new file mode 100644 index 00000000..93770516 --- /dev/null +++ b/packages/bruno-api-docs/playwright.standalone.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test'; +import base from './playwright.config'; +import { appConfig } from './e2e/config/app.config'; + +const [, testServer] = base.webServer as NonNullable[]; + +export default defineConfig({ + ...base, + testDir: './e2e/tests/standalone', + testIgnore: undefined, + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report/standalone' }], + ['json', { outputFile: 'playwright-report/standalone/results.json' }], + ], + webServer: [ + testServer, + { + command: 'npm run build:lib-bundle && npm run build:standalone && node e2e/server/test-server.mjs', + url: `${appConfig.standaloneURL}/health`, + env: { PORT: new URL(appConfig.standaloneURL).port }, + reuseExistingServer: !process.env.CI, + timeout: 300_000, + }, + ], +}); From f9a01cd42e783c1df958d282a947bb44f85853b4 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:43:14 +0530 Subject: [PATCH 13/73] move theme state out of redux into a per-instance theme object --- .../src/components/AppShell/AppShell.spec.tsx | 9 +- .../OpenCollection/OpenCollection.tsx | 19 +++-- .../JsonPreview/JsonPreview.tsx | 5 +- .../components/ThemeToggle/ThemeToggle.tsx | 18 ++-- .../src/observable/observable.test.ts | 82 +++++++++++++++++++ .../src/observable/observable.ts | 58 +++++++++++++ .../bruno-api-docs/src/session/session.tsx | 24 ++++++ .../src/store/slices/theme.test.ts | 45 ---------- .../bruno-api-docs/src/store/slices/theme.ts | 28 ------- .../bruno-api-docs/src/store/store.test.ts | 62 -------------- packages/bruno-api-docs/src/store/store.ts | 24 +----- .../src/theme/themeMode.test.ts | 82 +++++++++++++++++++ .../bruno-api-docs/src/theme/themeMode.ts | 34 ++++++-- .../src/ui/CodeEditor/CodeEditor.tsx | 5 +- packages/bruno-api-docs/src/utils/theme.ts | 8 -- 15 files changed, 303 insertions(+), 200 deletions(-) create mode 100644 packages/bruno-api-docs/src/observable/observable.test.ts create mode 100644 packages/bruno-api-docs/src/observable/observable.ts create mode 100644 packages/bruno-api-docs/src/session/session.tsx delete mode 100644 packages/bruno-api-docs/src/store/slices/theme.test.ts delete mode 100644 packages/bruno-api-docs/src/store/slices/theme.ts delete mode 100644 packages/bruno-api-docs/src/store/store.test.ts create mode 100644 packages/bruno-api-docs/src/theme/themeMode.test.ts diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx index 0511e82a..11bdcd04 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router-dom'; import { describe, it, expect } from 'vitest'; import type { OpenCollection } from '@opencollection/types'; import { createOpenCollectionStore } from '@/store/store'; +import { createSession, SessionProvider } from '@/session/session'; import { setDocsCollection } from '@/store/slices/docs'; import { hydrateWithUUIDs } from '@/utils/fileUtils'; import { useRenderToDom } from '@/hooks/useRenderToDom'; @@ -25,9 +26,11 @@ const useAppShellAt = (url: string, hidePlayground: boolean) => { store.dispatch(setDocsCollection(hydrateWithUUIDs(collection))); return useRenderToDom( - - - + + + + + ); }; diff --git a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx index dee00642..1f267393 100644 --- a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx @@ -28,7 +28,7 @@ import { } from '@/store/slices/app'; import { createOpenCollectionStore, type AppStore } from '@/store/store'; import { VariableResolverProvider } from '@/hooks'; -import { setTheme } from '@/store/slices/theme'; +import { createSession, SessionProvider, useSession, type Session } from '@/session/session'; import type { ThemeState } from '@/theme/types'; import { isEqual } from 'lodash-es'; import { getApiDocsTheme } from '@/utils/theme'; @@ -95,6 +95,7 @@ const OpenCollectionContent: React.FC = ({ hidePlayground = false }) => { const dispatch = useAppDispatch(); + const session = useSession(); const docsCollection = useAppSelector(selectDocsCollection); const collectionStatus = useAppSelector(selectCollectionStatus); const collectionError = useAppSelector(selectCollectionError); @@ -109,8 +110,8 @@ const OpenCollectionContent: React.FC = ({ if (isEqual(theme, lastThemeProp.current) && hideThemeSwitcher === lastHideThemeSwitcherProp.current) return; lastThemeProp.current = theme; lastHideThemeSwitcherProp.current = hideThemeSwitcher; - dispatch(setTheme(getApiDocsTheme(theme, hideThemeSwitcher))); - }, [theme, dispatch, hideThemeSwitcher]); + session.theme.set(getApiDocsTheme(theme, hideThemeSwitcher)); + }, [theme, session, hideThemeSwitcher]); useEffect(() => { let isActive = true; @@ -177,17 +178,21 @@ const OpenCollectionContent: React.FC = ({ const OpenCollection: React.FC = (props) => { const storeRef = useRef(null); + const sessionRef = useRef(null); if (!storeRef.current) { - storeRef.current = createOpenCollectionStore({ theme: getApiDocsTheme(props.theme, props.hideThemeSwitcher) }); + storeRef.current = createOpenCollectionStore(); + sessionRef.current = createSession({ theme: getApiDocsTheme(props.theme, props.hideThemeSwitcher) }); } return ( - - - + + + + + ); diff --git a/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/JsonPreview/JsonPreview.tsx b/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/JsonPreview/JsonPreview.tsx index ad4d32fe..e606127a 100644 --- a/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/JsonPreview/JsonPreview.tsx +++ b/packages/bruno-api-docs/src/components/Playground/QueryResultPreview/JsonPreview/JsonPreview.tsx @@ -1,7 +1,6 @@ import React, { Suspense, lazy } from 'react'; import ErrorBanner from '@/ui/ErrorBanner/ErrorBanner'; -import { useAppSelector } from '@/store/hooks'; -import { selectTheme } from '@/store/slices/theme'; +import { useThemeState } from '@/session/session'; import { StyledWrapper } from './StyledWrapper'; import { isDarkMode } from '@/utils/theme'; @@ -47,7 +46,7 @@ const parseJson = (input: unknown): ParsedJson => { /** Renders response data as an interactive, collapsible JSON tree. */ const JsonPreview: React.FC = ({ data }) => { - const { mode } = useAppSelector(selectTheme); + const { mode } = useThemeState(); const { value, error } = parseJson(data); if (error || value === null) { diff --git a/packages/bruno-api-docs/src/components/ThemeToggle/ThemeToggle.tsx b/packages/bruno-api-docs/src/components/ThemeToggle/ThemeToggle.tsx index 9563e7ce..5c531cf6 100644 --- a/packages/bruno-api-docs/src/components/ThemeToggle/ThemeToggle.tsx +++ b/packages/bruno-api-docs/src/components/ThemeToggle/ThemeToggle.tsx @@ -1,9 +1,8 @@ import React from 'react'; import styled from '@emotion/styled'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { selectTheme, setThemeMode } from '@/store/slices/theme'; +import { useSession, useThemeState } from '@/session/session'; import { Tooltip } from '@/ui/Tooltip/Tooltip'; -import { isDarkMode, persistThemeState } from '@/utils/theme'; +import { isDarkMode } from '@/utils/theme'; const Button = styled.button` display: inline-flex; @@ -44,24 +43,17 @@ const MoonIcon = () => ( ); const ThemeToggle: React.FC<{ testId?: string }> = ({ testId = 'theme-toggle' }) => { - const dispatch = useAppDispatch(); - const theme = useAppSelector(selectTheme); - const isDark = isDarkMode(theme.mode); + const { theme } = useSession(); + const isDark = isDarkMode(useThemeState().mode); const label = isDark ? 'Switch to light theme' : 'Switch to dark theme'; - const toggleTheme = () => { - const nextMode = isDark ? 'light' : 'dark'; - dispatch(setThemeMode(nextMode)); - persistThemeState({ ...theme, mode: nextMode }); - }; - return ( diff --git a/packages/bruno-api-docs/src/observable/observable.test.ts b/packages/bruno-api-docs/src/observable/observable.test.ts new file mode 100644 index 00000000..e7cc849c --- /dev/null +++ b/packages/bruno-api-docs/src/observable/observable.test.ts @@ -0,0 +1,82 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fakeStorage } from '@/test-utils/storage'; +import { createObservable, persisted, useObservable } from './observable'; + +describe('createObservable', () => { + it('starts at the initial value and returns what was set', () => { + const obs = createObservable({ initial: 1 }); + expect(obs.get()).toBe(1); + obs.set(2); + expect(obs.get()).toBe(2); + }); + + it('notifies subscribers, persists and calls onChange on a change, until unsubscribed', () => { + const write = vi.fn(); + const onChange = vi.fn(); + const listener = vi.fn(); + const obs = createObservable({ initial: 'a', persist: { write }, onChange }); + const unsubscribe = obs.subscribe(listener); + + obs.set('b'); + expect(listener).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith('b'); + expect(onChange).toHaveBeenCalledWith('b'); + + unsubscribe(); + obs.set('c'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does nothing when set to the same value', () => { + const write = vi.fn(); + const listener = vi.fn(); + const obs = createObservable({ initial: 'a', persist: { write } }); + obs.subscribe(listener); + obs.set('a'); + expect(write).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not persist the initial value', () => { + const write = vi.fn(); + createObservable({ initial: 'a', persist: { write } }); + expect(write).not.toHaveBeenCalled(); + }); +}); + +describe('useObservable', () => { + it('renders the current value', () => { + const obs = createObservable({ initial: 'first' }); + obs.set('second'); + const Probe = () => useObservable(obs); + expect(renderToStaticMarkup(createElement(Probe))).toBe('second'); + }); +}); + +describe('persisted', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('round-trips a value through JSON in the named storage', () => { + vi.stubGlobal('sessionStorage', fakeStorage()); + const store = persisted<{ a: number }>('sessionStorage', 'k'); + expect(store.read()).toBeUndefined(); + store.write({ a: 1 }); + expect(sessionStorage.getItem('k')).toBe('{"a":1}'); + expect(store.read()).toEqual({ a: 1 }); + }); + + it('reads malformed data as nothing', () => { + vi.stubGlobal('localStorage', fakeStorage()); + localStorage.setItem('k', '{not json'); + expect(persisted('localStorage', 'k').read()).toBeUndefined(); + }); + + it('tolerates missing storage', () => { + vi.stubGlobal('localStorage', undefined); + const store = persisted('localStorage', 'k'); + expect(store.read()).toBeUndefined(); + expect(() => store.write(1)).not.toThrow(); + }); +}); diff --git a/packages/bruno-api-docs/src/observable/observable.ts b/packages/bruno-api-docs/src/observable/observable.ts new file mode 100644 index 00000000..5286be31 --- /dev/null +++ b/packages/bruno-api-docs/src/observable/observable.ts @@ -0,0 +1,58 @@ +import { useSyncExternalStore } from 'react'; + +export interface Observable { + get: () => T; + set: (next: T) => void; + subscribe: (listener: () => void) => () => void; +} + +export interface Persisted { + read: () => T | undefined; + write: (value: T) => void; +} + +export const createObservable = ({ initial, persist, onChange }: { + initial: T; + persist?: Pick, 'write'>; + onChange?: (value: T) => void; +}): Observable => { + let value = initial; + const listeners = new Set<() => void>(); + return { + get: () => value, + set: (next) => { + if (Object.is(next, value)) return; + value = next; + persist?.write(value); + onChange?.(value); + listeners.forEach((listener) => listener()); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + }; +}; + +export const useObservable = (observable: Observable): T => + useSyncExternalStore(observable.subscribe, observable.get, observable.get); + +export const persisted = (storage: 'localStorage' | 'sessionStorage', key: string): Persisted => ({ + read: () => { + try { + const raw = globalThis[storage].getItem(key); + return raw === null ? undefined : (JSON.parse(raw) as T); + } catch { + return undefined; + } + }, + write: (value) => { + try { + globalThis[storage].setItem(key, JSON.stringify(value)); + } catch { + /* storage missing or blocked */ + } + } +}); diff --git a/packages/bruno-api-docs/src/session/session.tsx b/packages/bruno-api-docs/src/session/session.tsx new file mode 100644 index 00000000..7ee22f42 --- /dev/null +++ b/packages/bruno-api-docs/src/session/session.tsx @@ -0,0 +1,24 @@ +import React, { createContext, useContext } from 'react'; +import { useObservable } from '@/observable/observable'; +import { createThemeMode } from '@/theme/themeMode'; +import type { ThemeState } from '@/theme/types'; + +export const createSession = ({ theme }: { theme?: ThemeState } = {}) => ({ + theme: createThemeMode(theme) +}); + +export type Session = ReturnType; + +const SessionContext = createContext(null); + +export const SessionProvider: React.FC<{ session: Session; children: React.ReactNode }> = ({ session, children }) => ( + {children} +); + +export const useSession = (): Session => { + const session = useContext(SessionContext); + if (!session) throw new Error('useSession must be used inside '); + return session; +}; + +export const useThemeState = (): ThemeState => useObservable(useSession().theme); diff --git a/packages/bruno-api-docs/src/store/slices/theme.test.ts b/packages/bruno-api-docs/src/store/slices/theme.test.ts deleted file mode 100644 index 78290902..00000000 --- a/packages/bruno-api-docs/src/store/slices/theme.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; -import reducer, { setTheme, setThemeMode, selectTheme } from './theme'; -import { THEME_STORAGE_KEY } from '@/constants/theme'; -import type { RootState } from '@/store/store'; - -beforeAll(() => { - let store: Record = {}; - vi.stubGlobal('localStorage', { - getItem: (k: string) => (k in store ? store[k] : null), - setItem: (k: string, v: string) => { store[k] = String(v); }, - removeItem: (k: string) => { delete store[k]; }, - clear: () => { store = {}; } - }); -}); - -describe('themeSlice', () => { - beforeEach(() => localStorage.clear()); - - it('defaults to the default variant following the system mode', () => { - expect(reducer(undefined, { type: '@@INIT' })).toEqual({ variant: 'default', mode: 'system' }); - }); - - it('does not read localStorage for its fallback initial state', () => { - localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ variant: 'default', mode: 'dark' })); - expect(reducer(undefined, { type: '@@INIT' })).toEqual({ variant: 'default', mode: 'system' }); - }); - - it('setTheme replaces the whole state (pure, no side effects)', () => { - const next = reducer({ variant: 'default', mode: 'light' }, setTheme({ variant: 'backstage', mode: 'dark' })); - expect(next).toEqual({ variant: 'backstage', mode: 'dark' }); - expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); - }); - - it('setThemeMode changes the mode and keeps the variant', () => { - expect(reducer({ variant: 'backstage', mode: 'light' }, setThemeMode('dark'))) - .toEqual({ variant: 'backstage', mode: 'dark' }); - expect(reducer({ variant: 'default', mode: 'dark' }, setThemeMode('system'))) - .toEqual({ variant: 'default', mode: 'system' }); - }); - - it('selectTheme exposes the theme slice', () => { - const state = { theme: { variant: 'backstage', mode: 'dark' } } as unknown as RootState; - expect(selectTheme(state)).toEqual({ variant: 'backstage', mode: 'dark' }); - }); -}); diff --git a/packages/bruno-api-docs/src/store/slices/theme.ts b/packages/bruno-api-docs/src/store/slices/theme.ts deleted file mode 100644 index 3ef885d6..00000000 --- a/packages/bruno-api-docs/src/store/slices/theme.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { ThemeMode, ThemeState } from '@/theme/types'; -import { DEFAULT_THEME_VARIANT, DEFAULT_THEME_MODE } from '@/constants/theme'; -import type { RootState } from '@/store/store'; - -const themeSlice = createSlice({ - name: 'theme', - initialState: { - variant: DEFAULT_THEME_VARIANT, - mode: DEFAULT_THEME_MODE - }, - reducers: { - setTheme(_state, action: PayloadAction) { - return action.payload; - }, - setThemeMode(state, action: PayloadAction) { - return { - ...state, - mode: action.payload - }; - } - } -}); - -export const { setTheme, setThemeMode } = themeSlice.actions; -export default themeSlice.reducer; - -export const selectTheme = (state: RootState) => state.theme; diff --git a/packages/bruno-api-docs/src/store/store.test.ts b/packages/bruno-api-docs/src/store/store.test.ts deleted file mode 100644 index 01d5d20f..00000000 --- a/packages/bruno-api-docs/src/store/store.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; -import { setTheme, setThemeMode } from '@/store/slices/theme'; -import { THEME_STORAGE_KEY } from '@/constants/theme'; -import { createOpenCollectionStore } from './store'; - -const root = { attrs: {} as Record, setAttribute(k: string, v: string) { this.attrs[k] = v; } }; - -beforeAll(() => { - let store: Record = {}; - vi.stubGlobal('localStorage', { - getItem: (k: string) => (k in store ? store[k] : null), - setItem: (k: string, v: string) => { store[k] = String(v); }, - removeItem: (k: string) => { delete store[k]; }, - clear: () => { store = {}; } - }); -}); - -describe('createOpenCollectionStore theme seeding', () => { - beforeEach(() => { - localStorage.clear(); - root.attrs = {}; - vi.stubGlobal('document', { documentElement: root }); - }); - afterEach(() => vi.stubGlobal('document', undefined)); - - it('starts in the requested theme with no dispatch', () => { - const store = createOpenCollectionStore({ theme: { variant: 'backstage', mode: 'dark' } }); - expect(store.getState().theme).toEqual({ variant: 'backstage', mode: 'dark' }); - }); - - it('defaults to the default variant following the system mode, ignoring a stored mode', () => { - localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ variant: 'default', mode: 'dark' })); - expect(createOpenCollectionStore().getState().theme).toEqual({ variant: 'default', mode: 'system' }); - }); - - it('sets data-theme to the initial theme on creation', () => { - createOpenCollectionStore({ theme: { variant: 'backstage', mode: 'light' } }); - expect(root.attrs['data-theme']).toBe('backstage-light'); - }); - - it('does not persist the initial theme over the stored choice', () => { - localStorage.setItem(THEME_STORAGE_KEY, 'dark'); - createOpenCollectionStore({ theme: { variant: 'backstage', mode: 'light' } }); - expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark'); - }); - - it('applies a theme change to data-theme', () => { - const store = createOpenCollectionStore({ theme: { variant: 'backstage', mode: 'light' } }); - store.dispatch(setThemeMode('dark')); - expect(root.attrs['data-theme']).toBe('backstage-dark'); - - store.dispatch(setTheme({ variant: 'default', mode: 'light' })); - expect(root.attrs['data-theme']).toBe('default-light'); - }); - - it('never writes the theme to localStorage itself', () => { - const store = createOpenCollectionStore({ theme: { variant: 'backstage', mode: 'light' } }); - store.dispatch(setThemeMode('dark')); - store.dispatch(setTheme({ variant: 'default', mode: 'light' })); - expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); - }); -}); diff --git a/packages/bruno-api-docs/src/store/store.ts b/packages/bruno-api-docs/src/store/store.ts index 7a4e653a..17bb8794 100644 --- a/packages/bruno-api-docs/src/store/store.ts +++ b/packages/bruno-api-docs/src/store/store.ts @@ -3,38 +3,20 @@ import appReducer from '@/store/slices/app'; import docsReducer from '@/store/slices/docs'; import envReducer, { persistEnv } from '@/store/slices/env'; import playgroundReducer from '@/store/slices/playground'; -import themeReducer from '@/store/slices/theme'; -import { applyTheme } from '@/theme/applyTheme'; -import type { ThemeState } from '@/theme/types'; -import { isEqual } from 'lodash-es'; -export interface CreateOpenCollectionStoreOptions { - theme?: ThemeState; -} - -export const createOpenCollectionStore = ({ theme }: CreateOpenCollectionStoreOptions = {}) => { +export const createOpenCollectionStore = () => { const store = configureStore({ reducer: { app: appReducer, docs: docsReducer, env: envReducer, - playground: playgroundReducer, - theme: themeReducer - }, - ...(theme ? { preloadedState: { theme } } : {}) + playground: playgroundReducer + } }); - // Mirror theme changes to the root data-theme attribute outside the reducer. - let lastStateTheme = store.getState().theme; - applyTheme(lastStateTheme); let lastEnv = store.getState().env; store.subscribe(() => { const state = store.getState(); - const themeState = state.theme; - if (!isEqual(lastStateTheme, themeState)) { - lastStateTheme = themeState; - applyTheme(themeState); - } if (state.env !== lastEnv) { lastEnv = state.env; persistEnv(state.env); diff --git a/packages/bruno-api-docs/src/theme/themeMode.test.ts b/packages/bruno-api-docs/src/theme/themeMode.test.ts new file mode 100644 index 00000000..b7527f73 --- /dev/null +++ b/packages/bruno-api-docs/src/theme/themeMode.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { THEME_STORAGE_KEY } from '@/constants/theme'; +import { fakeStorage } from '@/test-utils/storage'; +import { createThemeMode } from './themeMode'; + +const root = { attrs: {} as Record, setAttribute(k: string, v: string) { this.attrs[k] = v; } }; + +describe('createThemeMode', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', fakeStorage()); + root.attrs = {}; + vi.stubGlobal('document', { documentElement: root }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('starts in the requested theme', () => { + expect(createThemeMode({ variant: 'backstage', mode: 'dark' }).get()).toEqual({ variant: 'backstage', mode: 'dark' }); + }); + + it('defaults to the default variant following the system mode, ignoring a stored mode', () => { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ variant: 'default', mode: 'dark' })); + expect(createThemeMode().get()).toEqual({ variant: 'default', mode: 'system' }); + }); + + it('sets data-theme to the initial theme on creation', () => { + createThemeMode({ variant: 'backstage', mode: 'light' }); + expect(root.attrs['data-theme']).toBe('backstage-light'); + }); + + it('does not persist the initial theme over the stored choice', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'dark'); + createThemeMode({ variant: 'backstage', mode: 'light' }); + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark'); + }); + + it('set replaces the whole theme, applies it to data-theme and notifies subscribers', () => { + const theme = createThemeMode({ variant: 'backstage', mode: 'light' }); + const listener = vi.fn(); + theme.subscribe(listener); + + theme.set({ variant: 'default', mode: 'dark' }); + + expect(theme.get()).toEqual({ variant: 'default', mode: 'dark' }); + expect(root.attrs['data-theme']).toBe('default-dark'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('set with an equal theme changes nothing', () => { + const theme = createThemeMode({ variant: 'backstage', mode: 'light' }); + const listener = vi.fn(); + theme.subscribe(listener); + root.attrs = {}; + + theme.set({ variant: 'backstage', mode: 'light' }); + + expect(listener).not.toHaveBeenCalled(); + expect(root.attrs['data-theme']).toBeUndefined(); + }); + + it('set never writes the theme to localStorage', () => { + createThemeMode({ variant: 'backstage', mode: 'light' }).set({ variant: 'default', mode: 'dark' }); + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); + }); + + it('toggle flips light and dark, keeps the variant, applies it and saves the whole theme', () => { + const theme = createThemeMode({ variant: 'backstage', mode: 'light' }); + + theme.toggle(); + expect(theme.get()).toEqual({ variant: 'backstage', mode: 'dark' }); + expect(root.attrs['data-theme']).toBe('backstage-dark'); + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe(JSON.stringify({ variant: 'backstage', mode: 'dark' })); + + theme.toggle(); + expect(theme.get()).toEqual({ variant: 'backstage', mode: 'light' }); + }); + + it('toggle from system goes to the opposite of what the system shows', () => { + const theme = createThemeMode({ variant: 'default', mode: 'system' }); + theme.toggle(); + expect(theme.get().mode).toBe('dark'); + }); +}); diff --git a/packages/bruno-api-docs/src/theme/themeMode.ts b/packages/bruno-api-docs/src/theme/themeMode.ts index 3ca36c77..4c9a6c95 100644 --- a/packages/bruno-api-docs/src/theme/themeMode.ts +++ b/packages/bruno-api-docs/src/theme/themeMode.ts @@ -1,10 +1,30 @@ -import type { ThemeMode } from './types'; +import { isEqual } from 'lodash-es'; +import { createObservable, persisted } from '@/observable/observable'; +import { DEFAULT_THEME_MODE, DEFAULT_THEME_VARIANT, THEME_STORAGE_KEY } from '@/constants/theme'; +import { isDarkMode } from '@/utils/theme'; +import { applyTheme } from './applyTheme'; +import type { ThemeState } from './types'; -export const themeMode = { - get: () => null, - set: (themeMode: ThemeMode) => themeMode, - toggle: () => null, - subscribe: () => null, - apply: () => null +export const createThemeMode = (initial: ThemeState = { variant: DEFAULT_THEME_VARIANT, mode: DEFAULT_THEME_MODE }) => { + const saved = persisted('localStorage', THEME_STORAGE_KEY); + const state = createObservable({ initial, onChange: applyTheme }); + const apply = () => applyTheme(state.get()); + apply(); + return { + get: state.get, + subscribe: state.subscribe, + apply, + set: (next: ThemeState) => { + if (!isEqual(next, state.get())) state.set(next); + }, + toggle: () => { + const current = state.get(); + const next: ThemeState = { ...current, mode: isDarkMode(current.mode) ? 'light' : 'dark' }; + state.set(next); + saved.write(next); + } + }; }; + +export type ThemeModeStore = ReturnType; diff --git a/packages/bruno-api-docs/src/ui/CodeEditor/CodeEditor.tsx b/packages/bruno-api-docs/src/ui/CodeEditor/CodeEditor.tsx index 13bf4e13..bd907d16 100644 --- a/packages/bruno-api-docs/src/ui/CodeEditor/CodeEditor.tsx +++ b/packages/bruno-api-docs/src/ui/CodeEditor/CodeEditor.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import Editor, { type Monaco, type OnMount } from '@monaco-editor/react'; -import { useAppSelector } from '@/store/hooks'; -import { selectTheme } from '@/store/slices/theme'; +import { useThemeState } from '@/session/session'; import { useResolvedVariables } from '@/hooks/useVariableResolver'; import { CopyButton } from '@/ui/CopyButton/CopyButton'; import { Portal } from '@/ui/Portal/Portal'; @@ -66,7 +65,7 @@ const CodeEditor: React.FC = ({ renderVariableCard, testId }) => { - const { mode } = useAppSelector(selectTheme); + const { mode } = useThemeState(); const resolver = useResolvedVariables(); const editorRef = useRef(null); const wrapperRef = useRef(null); diff --git a/packages/bruno-api-docs/src/utils/theme.ts b/packages/bruno-api-docs/src/utils/theme.ts index 5eef5cfd..f7683f01 100644 --- a/packages/bruno-api-docs/src/utils/theme.ts +++ b/packages/bruno-api-docs/src/utils/theme.ts @@ -81,12 +81,4 @@ export function getThemeValuesFromLocalStorage(themeSwitcherHidden: boolean): Pa return getThemeStateFrom(parsed); } -export const persistThemeState = (themeState: ThemeState) => { - try { - localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeState)); - } catch { - /* ignore */ - } -}; - export const isDarkMode = (themeMode: ThemeMode) => getAppThemeMode(themeMode) === 'dark'; From afb393ed301f1211f0d20a8a2c7b90f996a36658 Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:50:01 +0530 Subject: [PATCH 14/73] move the active environment out of redux into the per-instance session, which persists it --- .../src/components/AppShell/AppShell.spec.tsx | 15 ++-- .../EnvSwitcher/EnvSwitcher.spec.tsx | 32 +++---- .../components/EnvSwitcher/EnvSwitcher.tsx | 14 +-- .../OpenCollection/OpenCollection.tsx | 2 +- .../PlaygroundBody/PlaygroundBody.spec.tsx | 10 +-- .../PlaygroundBody/PlaygroundBody.tsx | 4 +- .../VariableInfoCard.spec.tsx | 33 ++++--- .../VariableText/VariableText.spec.tsx | 20 +++-- .../hooks/useVariableResolver.prompt.spec.tsx | 10 +-- .../src/hooks/useVariableResolver.spec.tsx | 51 ++++++----- .../src/hooks/useVariableResolver.tsx | 7 +- .../bruno-api-docs/src/session/env.test.ts | 86 +++++++++++++++++++ packages/bruno-api-docs/src/session/env.ts | 37 ++++++++ .../bruno-api-docs/src/session/session.tsx | 6 +- .../src/store/slices/env.test.ts | 62 ++----------- .../bruno-api-docs/src/store/slices/env.ts | 63 +------------- packages/bruno-api-docs/src/store/store.ts | 14 +-- .../src/test-utils/providers.tsx | 14 +++ 18 files changed, 260 insertions(+), 220 deletions(-) create mode 100644 packages/bruno-api-docs/src/session/env.test.ts create mode 100644 packages/bruno-api-docs/src/session/env.ts create mode 100644 packages/bruno-api-docs/src/test-utils/providers.tsx diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx index 11bdcd04..3eedc00f 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { describe, it, expect } from 'vitest'; import type { OpenCollection } from '@opencollection/types'; import { createOpenCollectionStore } from '@/store/store'; -import { createSession, SessionProvider } from '@/session/session'; +import { TestProviders } from '@/test-utils/providers'; import { setDocsCollection } from '@/store/slices/docs'; import { hydrateWithUUIDs } from '@/utils/fileUtils'; import { useRenderToDom } from '@/hooks/useRenderToDom'; @@ -25,13 +24,11 @@ const useAppShellAt = (url: string, hidePlayground: boolean) => { const store = createOpenCollectionStore(); store.dispatch(setDocsCollection(hydrateWithUUIDs(collection))); return useRenderToDom( - - - - - - - + + + + + ); }; diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx index 476ec2be..7da82b48 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { parse } from 'node-html-parser'; -import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; import type { OpenCollection } from '@opencollection/types'; import type { Environment } from '@opencollection/types/config/environments'; import { createOpenCollectionStore } from '@/store/store'; +import { TestProviders } from '@/test-utils/providers'; +import { createSession, type Session } from '@/session/session'; import { setDocsCollection } from '@/store/slices/docs'; -import { setActiveEnv } from '@/store/slices/env'; import { getByTestId, queryByTestId } from '@/test-utils/dom'; import EnvSwitcher, { type EnvSwitcherProps } from './EnvSwitcher'; @@ -17,17 +17,18 @@ const withEnvs = collectionWith([{ name: 'Dev' }, { name: 'Prod' }]); const render = ( collection: OpenCollection, - configure?: (s: ReturnType) => void, + configure?: (s: Session) => void, props?: Partial ) => { const store = createOpenCollectionStore(); store.dispatch(setDocsCollection(collection)); - configure?.(store); + const session = createSession(); + configure?.(session); const root = parse( renderToStaticMarkup( - + - + ) ); root.querySelectorAll('style').forEach((style) => style.remove()); @@ -36,19 +37,19 @@ const render = ( describe('EnvSwitcher', () => { it('shows the active environment name in the trigger', () => { - const trigger = getByTestId(render(withEnvs, (s) => s.dispatch(setActiveEnv('Prod'))), 'env-switcher'); + const trigger = getByTestId(render(withEnvs, (s) => s.env.setActiveEnv('Prod')), 'env-switcher'); expect(trigger.text).toContain('Prod'); }); it('marks the trigger as an accessible menu button', () => { - const trigger = getByTestId(render(withEnvs, (s) => s.dispatch(setActiveEnv('Prod'))), 'env-switcher'); + const trigger = getByTestId(render(withEnvs, (s) => s.env.setActiveEnv('Prod')), 'env-switcher'); expect(trigger.getAttribute('aria-label')).toBe('Select environment'); expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); expect(trigger.getAttribute('type')).toBe('button'); }); it('renders the trigger closed with no menu in the static markup', () => { - const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Prod'))); + const root = render(withEnvs, (s) => s.env.setActiveEnv('Prod')); expect(getByTestId(root, 'env-switcher').getAttribute('aria-expanded')).toBe('false'); expect(root.querySelector('[role="menu"]')).toBeNull(); }); @@ -58,7 +59,7 @@ describe('EnvSwitcher', () => { }); it('resolves a stale stored env to the first env in the render', () => { - const trigger = getByTestId(render(withEnvs, (s) => s.dispatch(setActiveEnv('Ghost'))), 'env-switcher'); + const trigger = getByTestId(render(withEnvs, (s) => s.env.setActiveEnv('Ghost')), 'env-switcher'); expect(trigger.text).toContain('Dev'); expect(trigger.text).not.toContain('Ghost'); }); @@ -77,7 +78,7 @@ describe('EnvSwitcher', () => { }); it('keeps the caret and the menu affordance when there are environments', () => { - const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev'))); + const root = render(withEnvs, (s) => s.env.setActiveEnv('Dev')); const trigger = getByTestId(root, 'env-switcher'); expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); expect(trigger.getAttribute('aria-disabled')).toBeFalsy(); @@ -91,7 +92,8 @@ describe('EnvSwitcher', () => { it('paints the trigger dot with the active env color', () => { const dot = getByTestId( - render(collectionWith([{ name: 'Dev', color: '#ff0000' }]), (s) => s.dispatch(setActiveEnv('Dev'))), + // eslint-disable-next-line no-restricted-syntax + render(collectionWith([{ name: 'Dev', color: '#ff0000' }]), (s) => s.env.setActiveEnv('Dev')), 'env-switcher' ).querySelector('.environment-label-dot'); expect(dot?.getAttribute('style')).toContain('#ff0000'); @@ -100,14 +102,14 @@ describe('EnvSwitcher', () => { it('renders the full env name in the trigger, clamped for the tooltip to reveal', () => { const long = 'development-staging-area-testing-qa-automation-local-setup-1'; - const root = render(collectionWith([{ name: long }]), (s) => s.dispatch(setActiveEnv(long))); + const root = render(collectionWith([{ name: long }]), (s) => s.env.setActiveEnv(long)); const trigger = getByTestId(root, 'env-switcher'); expect(trigger.text).toContain(long); expect(getByTestId(root, 'truncated-text').classList.contains('environment-label-name--clamped')).toBe(true); }); it('derives the root and trigger test ids from a custom testId', () => { - const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev')), { testId: 'playground-env-switcher' }); + const root = render(withEnvs, (s) => s.env.setActiveEnv('Dev'), { testId: 'playground-env-switcher' }); expect(getByTestId(root, 'playground-env-switcher-root')).toBeTruthy(); expect(getByTestId(root, 'playground-env-switcher')).toBeTruthy(); }); @@ -132,7 +134,7 @@ describe('EnvSwitcher hideWhenEmpty', () => { describe('EnvSwitcher name truncation', () => { it('drops the native title in favour of the styled tooltip anchor', () => { - const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev'))); + const root = render(withEnvs, (s) => s.env.setActiveEnv('Dev')); expect(getByTestId(root, 'env-switcher').getAttribute('title')).toBeFalsy(); expect(queryByTestId(root, 'truncated-text')).toBeTruthy(); }); diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx index cc2f97e3..11695ece 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo } from 'react'; import type { Environment } from '@opencollection/types/config/environments'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { useAppSelector } from '@/store/hooks'; import { selectDocsCollection } from '@/store/slices/docs'; -import { selectActiveEnvName, setActiveEnv } from '@/store/slices/env'; +import { useEnvState, useSession } from '@/session/session'; import { ChevronDownIcon } from '@/assets/icons'; import { EnvironmentLabel } from '../EnvironmentLabel/EnvironmentLabel'; import MenuDropdown, { type MenuDropdownItem } from '@/ui/MenuDropdown'; @@ -24,9 +24,9 @@ export interface EnvSwitcherProps { * above every playground dock. */ const EnvSwitcher: React.FC = ({ hideWhenEmpty = false, testId = 'env-switcher' }) => { - const dispatch = useAppDispatch(); + const { env } = useSession(); const collection = useAppSelector(selectDocsCollection); - const activeEnvName = useAppSelector(selectActiveEnvName); + const { activeEnvName } = useEnvState(); const environments = useMemo( () => (collection?.config?.environments ?? []) as Environment[], @@ -44,9 +44,9 @@ const EnvSwitcher: React.FC = ({ hideWhenEmpty = false, testId // so the persisted selection self-heals to a real environment. useEffect(() => { if (activeEnv && activeEnv.name !== activeEnvName) { - dispatch(setActiveEnv(activeEnv.name)); + env.setActiveEnv(activeEnv.name); } - }, [activeEnv, activeEnvName, dispatch]); + }, [activeEnv, activeEnvName, env]); if (hideWhenEmpty && !hasEnvironments) return null; @@ -62,7 +62,7 @@ const EnvSwitcher: React.FC = ({ hideWhenEmpty = false, testId ), ariaLabel: environment.name, title: environment.name, - onClick: () => dispatch(setActiveEnv(environment.name)) + onClick: () => env.setActiveEnv(environment.name) })) : []; diff --git a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx index 1f267393..c119bb3e 100644 --- a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx @@ -181,8 +181,8 @@ const OpenCollection: React.FC = (props) => { const sessionRef = useRef(null); if (!storeRef.current) { - storeRef.current = createOpenCollectionStore(); sessionRef.current = createSession({ theme: getApiDocsTheme(props.theme, props.hideThemeSwitcher) }); + storeRef.current = createOpenCollectionStore({ env: sessionRef.current.env }); } return ( diff --git a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx index a6d81792..bac3632f 100644 --- a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx +++ b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; vi.mock('@/ui/CodeEditor/CodeEditor', () => ({ @@ -9,6 +8,7 @@ vi.mock('@/ui/CodeEditor/CodeEditor', () => ({ import PlaygroundBody from './PlaygroundBody'; import { createOpenCollectionStore } from '@/store/store'; +import { TestProviders } from '@/test-utils/providers'; import { useRenderToDom } from '@/hooks/useRenderToDom'; import { getByTestId } from '@/test-utils/dom'; import { @@ -43,7 +43,7 @@ describe('PlaygroundBody example view', () => { const ref = React.createRef(); const root = useRenderToDom( - + { appliedSlugRef={ref as any} /> - + ); expect(root.querySelector('[data-testid="example-view"]')).not.toBeNull(); }); @@ -76,7 +76,7 @@ describe('PlaygroundBody script file view', () => { const ref = React.createRef(); const root = useRenderToDom( - + { appliedSlugRef={ref as any} /> - + ); expect(getByTestId(root, 'playground-script-file-title').textContent).toBe('helpers'); expect(getByTestId(root, 'code-editor').textContent).toBe('module.exports = { a: 1 };'); diff --git a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx index c60e01b6..767d3a92 100644 --- a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx +++ b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx @@ -14,7 +14,7 @@ import { toggleFolderCollapse, expandFolders } from '@/store/slices/playground'; -import { selectActiveEnvName } from '@/store/slices/env'; +import { useEnvState } from '@/session/session'; import type { ExampleHighlight } from '../../Docs/Sidebar/SidebarTree/SidebarTree'; import { useNavModel } from '@/routing/hooks'; import { usePlaygroundUrlState, useElementWidth, useResizableSidebar, useClickOutside } from '@/hooks'; @@ -69,7 +69,7 @@ const PlaygroundBody: React.FC = ({ const viewMode = useAppSelector(selectViewMode); const selectedItemId = useAppSelector(selectSelectedItemId); const selectedExampleIndex = useAppSelector(selectSelectedExampleIndex); - const activeEnvName = useAppSelector(selectActiveEnvName); + const { activeEnvName } = useEnvState(); const orientationOverride = useAppSelector(selectResponsePaneOrientation); const uuidToSlug = useMemo>(() => { diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx index fe1dca39..e3ea391e 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; import { createOpenCollectionStore } from '@/store/store'; +import { TestProviders } from '@/test-utils/providers'; +import { createSession } from '@/session/session'; import { setDocsCollection } from '@/store/slices/docs'; -import { setActiveEnv } from '@/store/slices/env'; import { VariableResolverProvider, ItemVariableResolverProvider } from '@/hooks'; import { useRenderToDom } from '@/hooks/useRenderToDom'; import { query } from '@/test-utils/dom'; @@ -45,14 +45,15 @@ const collection: any = { const cardTree = (name: string) => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(collection)); - store.dispatch(setActiveEnv('Dev')); + session.env.setActiveEnv('Dev'); return ( - + - + ); }; @@ -95,13 +96,15 @@ describe('VariableInfoCard', () => { expect(part(useRenderToDom(cardTree('vaultKey')), 'scope').text).toBe('Undefined'); const store = createOpenCollectionStore(); - store.dispatch(setActiveEnv('Dev')); + + const session = createSession(); + session.env.setActiveEnv('Dev'); const docsItemTree = ( - + - + ); expect(part(useRenderToDom(docsItemTree), 'scope').text).toBe('Undefined'); }); @@ -154,13 +157,14 @@ describe('VariableInfoCard', () => { // Editing needs a writable resolver, which only ItemVariableResolverProvider supplies. const editableCardTree = (name: string) => { const store = createOpenCollectionStore(); - store.dispatch(setActiveEnv('Dev')); + const session = createSession(); + session.env.setActiveEnv('Dev'); return ( - + - + ); }; @@ -184,14 +188,15 @@ describe('VariableInfoCard (editable)', () => { it('stays read-only when the resolver cannot write, even with editable set', () => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(collection)); - store.dispatch(setActiveEnv('Dev')); + session.env.setActiveEnv('Dev'); const tree = ( - + - + ); const value = part(useRenderToDom(tree), 'value'); expect(value.classList.contains('var-value-editable')).toBe(false); diff --git a/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx b/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx index 8c815ec5..ece479f1 100644 --- a/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; -import { createOpenCollectionStore } from '@/store/store'; +import { createOpenCollectionStore, type AppStore } from '@/store/store'; +import { TestProviders } from '@/test-utils/providers'; +import { createSession, type Session } from '@/session/session'; import { setDocsCollection } from '@/store/slices/docs'; -import { setActiveEnv, setShowVars } from '@/store/slices/env'; +import { setShowVars } from '@/store/slices/env'; import { VariableResolverProvider } from '@/hooks'; import { useRenderToDom } from '@/hooks/useRenderToDom'; import { VariableText } from './VariableText'; @@ -12,21 +13,22 @@ const collection: any = { config: { environments: [{ name: 'Dev', variables: [{ name: 'baseUrl', value: 'https://dev.test' }] }] } }; -const tree = (value: string, configure?: (store: ReturnType) => void) => { +const tree = (value: string, configure?: (session: Session, store: AppStore) => void) => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(collection)); - configure?.(store); + configure?.(session, store); return ( - + - + ); }; -const withShowVars = (store: ReturnType) => { - store.dispatch(setActiveEnv('Dev')); +const withShowVars = (session: Session, store: AppStore) => { + session.env.setActiveEnv('Dev'); store.dispatch(setShowVars(true)); }; diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx index 9d85520c..9e4f03f9 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; import { createOpenCollectionStore } from '@/store/store'; +import { TestProviders } from '@/test-utils/providers'; import { setPlaygroundCollection } from '@/store/slices/playground'; import { ItemVariableResolverProvider, @@ -28,11 +28,11 @@ const probeTree = (store: ReturnType) => { return { box, tree: ( - + - + ) }; }; @@ -107,11 +107,11 @@ describe('a prompt token outside the playground', () => { }; useRenderToDom( - + - + ); expect((box.current as VariableResolver).lookup('?Region').scope).toBe('prompt'); diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx index 3f9af104..51b32ff2 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; -import { createOpenCollectionStore } from '@/store/store'; +import { createOpenCollectionStore, type AppStore } from '@/store/store'; +import { TestProviders } from '@/test-utils/providers'; +import { createSession, type Session } from '@/session/session'; import { setDocsCollection } from '@/store/slices/docs'; -import { setActiveEnv, setShowVars } from '@/store/slices/env'; +import { setShowVars } from '@/store/slices/env'; import { useVariableResolver, useResolvedVariables, ItemVariableResolverProvider } from './useVariableResolver'; const collection: any = { @@ -35,27 +36,28 @@ const Probe: React.FC = () => { ); }; -const render = (configure: (store: ReturnType) => void): string => { +const render = (configure: (session: Session, store: AppStore) => void): string => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(collection)); - configure(store); + configure(session, store); return renderToStaticMarkup( - + - + ); }; describe('useVariableResolver', () => { it('returns raw placeholders when showVars is off', () => { - const html = render((s) => s.dispatch(setActiveEnv('Dev'))); + const html = render((session) => session.env.setActiveEnv('Dev')); expect(html).toContain('{{baseUrl}}'); expect(html).toContain('{{collectionVar}}'); }); it('resolves env + collection vars against the active env when on', () => { - const html = render((s) => { - s.dispatch(setActiveEnv('Dev')); + const html = render((session, s) => { + session.env.setActiveEnv('Dev'); s.dispatch(setShowVars(true)); }); expect(html).toContain('https://dev.test'); @@ -63,16 +65,16 @@ describe('useVariableResolver', () => { }); it('reflects the active env (switching changes the value)', () => { - const html = render((s) => { - s.dispatch(setActiveEnv('Prod')); + const html = render((session, s) => { + session.env.setActiveEnv('Prod'); s.dispatch(setShowVars(true)); }); expect(html).toContain('https://prod.test'); }); it('never resolves a secret to plaintext, even when on', () => { - const html = render((s) => { - s.dispatch(setActiveEnv('Dev')); + const html = render((session, s) => { + session.env.setActiveEnv('Dev'); s.dispatch(setShowVars(true)); }); expect(html).toContain('authToken'); @@ -98,15 +100,16 @@ describe('lookup (variable hover card)', () => { const renderLookup = (name: string, showVars = false): string => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(collection)); - store.dispatch(setActiveEnv('Dev')); + session.env.setActiveEnv('Dev'); if (showVars) store.dispatch(setShowVars(true)); return renderToStaticMarkup( - + - + ); }; @@ -165,16 +168,17 @@ describe('nested variable resolution', () => { it('follows a variable that points at another variable, for display and for interpolation', () => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(nested)); - store.dispatch(setActiveEnv('Dev')); + session.env.setActiveEnv('Dev'); store.dispatch(setShowVars(true)); const html = renderToStaticMarkup( - + - + ); expect(html).toContain('https://api.test/v1'); @@ -183,16 +187,17 @@ describe('nested variable resolution', () => { it('gates resolve on showVars but never interpolate', () => { const store = createOpenCollectionStore(); + const session = createSession(); store.dispatch(setDocsCollection(nested)); - store.dispatch(setActiveEnv('Dev')); + session.env.setActiveEnv('Dev'); store.dispatch(setShowVars(false)); const html = renderToStaticMarkup( - + - + ); expect(html).toContain('{{endpoint}}'); diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx index 8d1c8beb..1d48c3a0 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx @@ -7,7 +7,8 @@ import { useAppDispatch, useAppSelector } from '@/store/hooks'; import { selectDocsCollection } from '@/store/slices/docs'; import { setPlaygroundVariable } from '@/store/slices/playground'; import { promptVariableName } from '@/utils/promptVariables'; -import { selectActiveEnvName, selectShowVars } from '@/store/slices/env'; +import { selectShowVars } from '@/store/slices/env'; +import { useEnvState } from '@/session/session'; import { getRequestVariables, isFolder } from '@/utils/schemaHelpers'; import { getItemUuid } from '@/utils/itemUtils'; import { mockDataFunctions, timeBasedDynamicVars } from '@/runner/utils/faker-functions'; @@ -151,7 +152,7 @@ const itemSource = (item: Item): VariableSource => export const useVariableResolver = (): VariableResolver => { const collection = useAppSelector(selectDocsCollection) as OpenCollection | null; - const activeEnvName = useAppSelector(selectActiveEnvName); + const { activeEnvName } = useEnvState(); const showVars = useAppSelector(selectShowVars); const model = useMemo( @@ -230,7 +231,7 @@ export const ItemVariableResolverProvider: React.FC<{ children: React.ReactNode; }> = ({ collection, ancestry, item, writable = false, children }) => { const dispatch = useAppDispatch(); - const activeEnvName = useAppSelector(selectActiveEnvName); + const { activeEnvName } = useEnvState(); const showVars = useAppSelector(selectShowVars); // Both the docs pages and the playground mount this provider; only the diff --git a/packages/bruno-api-docs/src/session/env.test.ts b/packages/bruno-api-docs/src/session/env.test.ts new file mode 100644 index 00000000..880eceb8 --- /dev/null +++ b/packages/bruno-api-docs/src/session/env.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { fakeStorage } from '@/test-utils/storage'; +import { createEnv, readPersistedEnv, ENV_STORAGE_KEY } from './env'; + +beforeEach(() => vi.stubGlobal('sessionStorage', fakeStorage())); +afterEach(() => vi.unstubAllGlobals()); + +describe('createEnv', () => { + it('defaults to no active env and showVars off', () => { + expect(createEnv().get()).toEqual({ activeEnvName: null, showVars: false }); + }); + + it('setActiveEnv sets the active environment name', () => { + const env = createEnv(); + env.setActiveEnv('Production'); + expect(env.get().activeEnvName).toBe('Production'); + env.setActiveEnv(null); + expect(env.get().activeEnvName).toBeNull(); + }); + + it('setShowVars sets the flag explicitly', () => { + const env = createEnv(); + env.setShowVars(true); + expect(env.get().showVars).toBe(true); + env.setShowVars(false); + expect(env.get().showVars).toBe(false); + }); + + it('keeps the two fields independent', () => { + const env = createEnv(); + env.setActiveEnv('Dev'); + env.setShowVars(true); + expect(env.get()).toEqual({ activeEnvName: 'Dev', showVars: true }); + }); + + it('saves every change to sessionStorage, field order kept', () => { + const env = createEnv(); + env.setActiveEnv('Prod'); + expect(sessionStorage.getItem(ENV_STORAGE_KEY)).toBe(JSON.stringify({ activeEnvName: 'Prod', showVars: false })); + }); + + it('neither saves nor notifies when a field is set to what it already is', () => { + const env = createEnv({ activeEnvName: 'Dev', showVars: false }); + const listener = vi.fn(); + env.subscribe(listener); + env.setActiveEnv('Dev'); + env.setShowVars(false); + expect(listener).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(ENV_STORAGE_KEY)).toBeNull(); + }); + + it('two envs are independent', () => { + const first = createEnv(); + const second = createEnv(); + first.setActiveEnv('Prod'); + expect(second.get().activeEnvName).toBeNull(); + }); +}); + +describe('readPersistedEnv', () => { + it('returns defaults when nothing is stored', () => { + expect(readPersistedEnv()).toEqual({ activeEnvName: null, showVars: false }); + }); + + it('round-trips what an env saved', () => { + const env = createEnv(); + env.setActiveEnv('Prod'); + env.setShowVars(true); + expect(readPersistedEnv()).toEqual({ activeEnvName: 'Prod', showVars: true }); + }); + + it('keeps whatever on/off state was saved, including off', () => { + sessionStorage.setItem(ENV_STORAGE_KEY, JSON.stringify({ activeEnvName: 'Dev', showVars: false })); + expect(readPersistedEnv().showVars).toBe(false); + }); + + it('ignores malformed stored data', () => { + sessionStorage.setItem(ENV_STORAGE_KEY, '{not json'); + expect(readPersistedEnv()).toEqual({ activeEnvName: null, showVars: false }); + }); + + it('drops fields of the wrong type', () => { + sessionStorage.setItem(ENV_STORAGE_KEY, JSON.stringify({ activeEnvName: 3, showVars: 'yes' })); + expect(readPersistedEnv()).toEqual({ activeEnvName: null, showVars: false }); + }); +}); diff --git a/packages/bruno-api-docs/src/session/env.ts b/packages/bruno-api-docs/src/session/env.ts new file mode 100644 index 00000000..b10376ae --- /dev/null +++ b/packages/bruno-api-docs/src/session/env.ts @@ -0,0 +1,37 @@ +import { createObservable, persisted } from '@/observable/observable'; + +export interface EnvState { + activeEnvName: string | null; + showVars: boolean; +} + +export const ENV_STORAGE_KEY = 'oc-docs.env'; + +const saved = persisted>('sessionStorage', ENV_STORAGE_KEY); + +export const readPersistedEnv = (): EnvState => { + const stored = saved.read(); + return { + activeEnvName: typeof stored?.activeEnvName === 'string' ? stored.activeEnvName : null, + showVars: stored?.showVars === true + }; +}; + +const startingEnv = readPersistedEnv(); + +export const createEnv = (initial: EnvState = startingEnv) => { + const state = createObservable({ initial, persist: saved }); + const setField = (key: K, value: EnvState[K]) => { + if (state.get()[key] !== value) state.set({ ...state.get(), [key]: value }); + }; + + return { + get: state.get, + set: state.set, + subscribe: state.subscribe, + setActiveEnv: (name: string | null) => setField('activeEnvName', name), + setShowVars: (on: boolean) => setField('showVars', on) + }; +}; + +export type EnvSession = ReturnType; diff --git a/packages/bruno-api-docs/src/session/session.tsx b/packages/bruno-api-docs/src/session/session.tsx index 7ee22f42..fd34ae09 100644 --- a/packages/bruno-api-docs/src/session/session.tsx +++ b/packages/bruno-api-docs/src/session/session.tsx @@ -2,9 +2,11 @@ import React, { createContext, useContext } from 'react'; import { useObservable } from '@/observable/observable'; import { createThemeMode } from '@/theme/themeMode'; import type { ThemeState } from '@/theme/types'; +import { createEnv, type EnvState } from './env'; export const createSession = ({ theme }: { theme?: ThemeState } = {}) => ({ - theme: createThemeMode(theme) + theme: createThemeMode(theme), + env: createEnv() }); export type Session = ReturnType; @@ -22,3 +24,5 @@ export const useSession = (): Session => { }; export const useThemeState = (): ThemeState => useObservable(useSession().theme); + +export const useEnvState = (): EnvState => useObservable(useSession().env); diff --git a/packages/bruno-api-docs/src/store/slices/env.test.ts b/packages/bruno-api-docs/src/store/slices/env.test.ts index fe9b2b23..dfa224f4 100644 --- a/packages/bruno-api-docs/src/store/slices/env.test.ts +++ b/packages/bruno-api-docs/src/store/slices/env.test.ts @@ -1,68 +1,18 @@ -import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; -import reducer, { - setActiveEnv, - setShowVars, - toggleShowVars, - readPersistedEnv, - persistEnv, - ENV_STORAGE_KEY -} from './env'; - -beforeAll(() => { - let store: Record = {}; - vi.stubGlobal('sessionStorage', { - getItem: (k: string) => (k in store ? store[k] : null), - setItem: (k: string, v: string) => { store[k] = String(v); }, - removeItem: (k: string) => { delete store[k]; }, - clear: () => { store = {}; } - }); -}); +import { describe, it, expect } from 'vitest'; +import reducer, { setShowVars, toggleShowVars } from './env'; describe('envSlice', () => { - it('defaults to no active env and showVars off', () => { - expect(reducer(undefined, { type: '@@INIT' })).toEqual({ activeEnvName: null, showVars: false }); - }); - - it('setActiveEnv sets the active environment name', () => { - expect(reducer(undefined, setActiveEnv('Production')).activeEnvName).toBe('Production'); - expect(reducer({ activeEnvName: 'Production', showVars: false }, setActiveEnv(null)).activeEnvName).toBeNull(); + it('defaults to showVars off', () => { + expect(reducer(undefined, { type: '@@INIT' })).toEqual({ showVars: false }); }); it('setShowVars sets the flag explicitly', () => { expect(reducer(undefined, setShowVars(true)).showVars).toBe(true); - expect(reducer({ activeEnvName: null, showVars: true }, setShowVars(false)).showVars).toBe(false); + expect(reducer({ showVars: true }, setShowVars(false)).showVars).toBe(false); }); it('toggleShowVars flips the flag', () => { expect(reducer(undefined, toggleShowVars()).showVars).toBe(true); - expect(reducer({ activeEnvName: null, showVars: true }, toggleShowVars()).showVars).toBe(false); - }); - - it('keeps the two fields independent', () => { - const next = reducer(reducer(undefined, setActiveEnv('Dev')), toggleShowVars()); - expect(next).toEqual({ activeEnvName: 'Dev', showVars: true }); - }); -}); - -describe('envSlice persistence (sessionStorage)', () => { - beforeEach(() => sessionStorage.clear()); - - it('readPersistedEnv returns defaults when nothing is stored', () => { - expect(readPersistedEnv()).toEqual({ activeEnvName: null, showVars: false }); - }); - - it('persistEnv round-trips the selection', () => { - persistEnv({ activeEnvName: 'Prod', showVars: true }); - expect(readPersistedEnv()).toEqual({ activeEnvName: 'Prod', showVars: true }); - }); - - it('persists whatever on/off state was set, including off', () => { - persistEnv({ activeEnvName: 'Dev', showVars: false }); - expect(readPersistedEnv().showVars).toBe(false); - }); - - it('ignores malformed stored data', () => { - sessionStorage.setItem(ENV_STORAGE_KEY, '{not json'); - expect(readPersistedEnv()).toEqual({ activeEnvName: null, showVars: false }); + expect(reducer({ showVars: true }, toggleShowVars()).showVars).toBe(false); }); }); diff --git a/packages/bruno-api-docs/src/store/slices/env.ts b/packages/bruno-api-docs/src/store/slices/env.ts index 095bd789..40976a4f 100644 --- a/packages/bruno-api-docs/src/store/slices/env.ts +++ b/packages/bruno-api-docs/src/store/slices/env.ts @@ -1,67 +1,11 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '@/store/store'; - -/** - * Which environment the docs are viewing (`activeEnvName`) and whether resolved - * values are shown (`showVars`, off by default so `{{var}}` placeholders show - * until the reader turns it on). The env switcher sets these; the resolver reads - * them to display values. (The playground keeps its own environment separately.) - * - * Environments are keyed by name. A saved name that no longer exists in the - * collection is treated as "none", and the switcher falls back to the first one. - */ -export interface EnvState { - activeEnvName: string | null; - showVars: boolean; -} - -export const ENV_STORAGE_KEY = 'oc-docs.env'; - -const defaultState: EnvState = { - activeEnvName: null, - showVars: false -}; - -/** - * Read the persisted selection from sessionStorage (per-tab, survives reload). - * Falls back to defaults when nothing is stored or storage is unavailable - * (standalone build edge cases, node tests). - */ -export const readPersistedEnv = (): EnvState => { - try { - const raw = sessionStorage.getItem(ENV_STORAGE_KEY); - if (!raw) return defaultState; - const parsed = JSON.parse(raw) as Partial; - return { - activeEnvName: typeof parsed.activeEnvName === 'string' ? parsed.activeEnvName : null, - showVars: parsed.showVars === true - }; - } catch { - return defaultState; - } -}; - -/** - * Side effect: mirror the selection to sessionStorage. Kept out of the reducers - * (which stay pure); call it from the store subscription (see store.ts). - */ -export const persistEnv = (state: EnvState) => { - try { - sessionStorage.setItem(ENV_STORAGE_KEY, JSON.stringify(state)); - } catch { - /* ignore */ - } -}; - -const initialState: EnvState = readPersistedEnv(); +import { readPersistedEnv } from '@/session/env'; const envSlice = createSlice({ name: 'env', - initialState, + initialState: { showVars: readPersistedEnv().showVars }, reducers: { - setActiveEnv(state, action: PayloadAction) { - state.activeEnvName = action.payload; - }, setShowVars(state, action: PayloadAction) { state.showVars = action.payload; }, @@ -71,9 +15,8 @@ const envSlice = createSlice({ } }); -export const { setActiveEnv, setShowVars, toggleShowVars } = envSlice.actions; +export const { setShowVars, toggleShowVars } = envSlice.actions; -export const selectActiveEnvName = (state: RootState): string | null => state.env.activeEnvName; export const selectShowVars = (state: RootState): boolean => state.env.showVars; export default envSlice.reducer; diff --git a/packages/bruno-api-docs/src/store/store.ts b/packages/bruno-api-docs/src/store/store.ts index 17bb8794..b28df31c 100644 --- a/packages/bruno-api-docs/src/store/store.ts +++ b/packages/bruno-api-docs/src/store/store.ts @@ -1,10 +1,11 @@ import { configureStore } from '@reduxjs/toolkit'; import appReducer from '@/store/slices/app'; import docsReducer from '@/store/slices/docs'; -import envReducer, { persistEnv } from '@/store/slices/env'; +import envReducer from '@/store/slices/env'; import playgroundReducer from '@/store/slices/playground'; +import type { EnvSession } from '@/session/env'; -export const createOpenCollectionStore = () => { +export const createOpenCollectionStore = ({ env }: { env?: EnvSession } = {}) => { const store = configureStore({ reducer: { app: appReducer, @@ -14,14 +15,7 @@ export const createOpenCollectionStore = () => { } }); - let lastEnv = store.getState().env; - store.subscribe(() => { - const state = store.getState(); - if (state.env !== lastEnv) { - lastEnv = state.env; - persistEnv(state.env); - } - }); + store.subscribe(() => env?.setShowVars(store.getState().env.showVars)); return store; }; diff --git a/packages/bruno-api-docs/src/test-utils/providers.tsx b/packages/bruno-api-docs/src/test-utils/providers.tsx new file mode 100644 index 00000000..0f37f21d --- /dev/null +++ b/packages/bruno-api-docs/src/test-utils/providers.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import type { AppStore } from '@/store/store'; +import { createSession, SessionProvider, type Session } from '@/session/session'; + +export const TestProviders: React.FC<{ store: AppStore; session?: Session; children: React.ReactNode }> = ({ + store, + session = createSession(), + children +}) => ( + + {children} + +); From 0eeedabd7f44ce09d1b84e70ca16b00cbef301fc Mon Sep 17 00:00:00 2001 From: Arpit Bruno Date: Thu, 12 Nov 2026 19:53:24 +0530 Subject: [PATCH 15/73] move show vars into the session env and delete the env slice and the store subscribe pump --- .../components/EnvSwitcher/EnvSwitcher.tsx | 2 +- .../OpenCollection/OpenCollection.tsx | 2 +- .../ShowVarsToggle/ShowVarsToggle.spec.tsx | 16 ++++++------- .../ShowVarsToggle/ShowVarsToggle.tsx | 13 +++++----- .../VariableText/VariableText.spec.tsx | 11 ++++----- .../src/hooks/useVariableResolver.spec.tsx | 24 +++++++++---------- .../src/hooks/useVariableResolver.tsx | 9 +++---- .../bruno-api-docs/src/session/env.test.ts | 13 +++++----- packages/bruno-api-docs/src/session/env.ts | 4 +--- .../src/store/slices/env.test.ts | 18 -------------- .../bruno-api-docs/src/store/slices/env.ts | 22 ----------------- packages/bruno-api-docs/src/store/store.ts | 12 ++-------- .../bruno-api-docs/src/theme/themeMode.ts | 2 -- 13 files changed, 43 insertions(+), 105 deletions(-) delete mode 100644 packages/bruno-api-docs/src/store/slices/env.test.ts delete mode 100644 packages/bruno-api-docs/src/store/slices/env.ts diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx index 11695ece..1153fc1a 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx @@ -15,7 +15,7 @@ export interface EnvSwitcherProps { /** * Environment switcher for the docs: lets the reader pick which environment's - * values are shown, storing the choice in the env slice. Self-contained (no + * values are shown, storing the choice in the session env. Self-contained (no * layout assumptions) so it can sit in the topbar or elsewhere. The dot + name * reuse `EnvironmentLabel`. The menu renders through `MenuDropdown` (Tippy), * which portals to , positions against the trigger, tracks scroll/resize, diff --git a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx index c119bb3e..c71f1fcc 100644 --- a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx @@ -182,7 +182,7 @@ const OpenCollection: React.FC = (props) => { if (!storeRef.current) { sessionRef.current = createSession({ theme: getApiDocsTheme(props.theme, props.hideThemeSwitcher) }); - storeRef.current = createOpenCollectionStore({ env: sessionRef.current.env }); + storeRef.current = createOpenCollectionStore(); } return ( diff --git a/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.spec.tsx b/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.spec.tsx index 4bdb685e..97e6bc54 100644 --- a/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.spec.tsx +++ b/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.spec.tsx @@ -1,18 +1,16 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; -import { createOpenCollectionStore } from '@/store/store'; -import { setShowVars } from '@/store/slices/env'; +import { createSession, SessionProvider, type Session } from '@/session/session'; import ShowVarsToggle from './ShowVarsToggle'; -const render = (configure?: (s: ReturnType) => void): string => { - const store = createOpenCollectionStore(); - configure?.(store); +const render = (configure?: (s: Session) => void): string => { + const session = createSession(); + configure?.(session); return renderToStaticMarkup( - + - + ); }; @@ -25,6 +23,6 @@ describe('ShowVarsToggle', () => { }); it('reflects the showVars flag via aria-checked', () => { - expect(render((s) => s.dispatch(setShowVars(true)))).toContain('aria-checked="true"'); + expect(render((s) => s.env.toggleShowVars())).toContain('aria-checked="true"'); }); }); diff --git a/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.tsx b/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.tsx index c7cb5460..5b0808a4 100644 --- a/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.tsx +++ b/packages/bruno-api-docs/src/components/ShowVarsToggle/ShowVarsToggle.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { selectShowVars, toggleShowVars } from '@/store/slices/env'; +import { useEnvState, useSession } from '@/session/session'; import { Tooltip } from '@/ui/Tooltip/Tooltip'; import { StyledWrapper } from './StyledWrapper'; @@ -9,13 +8,13 @@ export interface ShowVarsToggleProps { } /** - * Placement-agnostic show-variables toggle. Bound to the single global - * `showVars` flag in the env slice, so it flips the same state from any + * Placement-agnostic show-variables toggle. Bound to the instance's + * `showVars` flag in the session env, so it flips the same state from any * container alongside the environment switcher. */ const ShowVarsToggle: React.FC = ({ testId = 'show-vars-toggle' }) => { - const dispatch = useAppDispatch(); - const showVars = useAppSelector(selectShowVars); + const { env } = useSession(); + const { showVars } = useEnvState(); return ( @@ -24,7 +23,7 @@ const ShowVarsToggle: React.FC = ({ testId = 'show-vars-tog data-testid={testId} aria-checked={showVars} aria-label="Show variables" - onClick={() => dispatch(toggleShowVars())} + onClick={env.toggleShowVars} >