Skip to content

Commit 78e6d80

Browse files
fix(api-docs): keep playground edits when it is closed and reopened (#104)
* fix(api-docs): keep edits when the playground is closed and reopened * fix(api-docs): tighten tests for keeping playground edits on reopen * review comments addressed * Update .changeset/playground-keep-edits.md Co-authored-by: Arpit <arpit@usebruno.com> * test(api-docs): drop the unused Staging environment from the vars fixture --------- Co-authored-by: Arpit <arpit@usebruno.com>
1 parent 056b42b commit 78e6d80

5 files changed

Lines changed: 195 additions & 50 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@usebruno/api-docs": patch
3+
---
4+
5+
The playground keeps your edits when you close and reopen it. Environment values (secrets included), and request, folder and collection edits stay until the page is reloaded, and a reload restores the collection's original values. The selected environment is remembered for the tab, as before.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { Page } from '@playwright/test';
2+
import { test, expect } from '../../playwright';
3+
import type { PlaygroundComponent } from '../../components/playground.component';
4+
import type { RequestPage } from '../../pages/request.page';
5+
import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component';
6+
7+
const markPageLoad = (page: Page) => page.evaluate(() => {
8+
(window as unknown as { __sameLoad: boolean }).__sameLoad = true;
9+
});
10+
11+
const isSamePageLoad = (page: Page) =>
12+
page.evaluate(() => (window as unknown as { __sameLoad?: boolean }).__sameLoad === true);
13+
14+
const closeAndReopen = async (page: Page, playground: PlaygroundComponent, requestPage: RequestPage): Promise<void> => {
15+
await markPageLoad(page);
16+
await playground.close();
17+
await expect(playground.header).toHaveCount(0);
18+
await requestPage.urlBar.tryButton.click();
19+
await expect(playground.header).toBeVisible();
20+
await playground.view.waitFor({ state: 'visible' });
21+
expect(await isSamePageLoad(page)).toBe(true);
22+
};
23+
24+
const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise<void> => {
25+
await editor.focus();
26+
await page.keyboard.press('ControlOrMeta+a');
27+
await page.keyboard.insertText(script);
28+
};
29+
30+
test.describe('Playground edits survive closing and reopening, not a reload', () => {
31+
test.beforeEach(async ({ page, requestPage, playground }) => {
32+
await page.goto('/?fixture=vars');
33+
await requestPage.sidebar.open(['Customers', 'Variables Demo']);
34+
await requestPage.root.waitFor({ state: 'visible' });
35+
await requestPage.urlBar.tryButton.click();
36+
await playground.view.waitFor({ state: 'visible' });
37+
await playground.envSwitcher.selectEnvironment('Dev');
38+
});
39+
40+
test('a typed secret environment value is still set after close and reopen', async ({
41+
page,
42+
playground,
43+
requestPage
44+
}) => {
45+
await playground.variable.hoverInputToken('unsetSecret');
46+
await playground.variable.editTo('typed-secret');
47+
await expect(playground.variable.value).toHaveText('*'.repeat('typed-secret'.length));
48+
49+
await closeAndReopen(page, playground, requestPage);
50+
51+
await playground.variable.hoverInputToken('unsetSecret');
52+
await playground.variable.revealToggle.click();
53+
await expect(playground.variable.value).toHaveText('typed-secret');
54+
});
55+
56+
test('an edited environment value and request URL are kept after close and reopen', async ({
57+
page,
58+
playground,
59+
requestPage
60+
}) => {
61+
await playground.variable.hoverInputToken('host');
62+
await playground.variable.editTo('https://edited.example.com');
63+
await expect(playground.variable.value).toHaveText('https://edited.example.com');
64+
65+
await playground.urlInput.click();
66+
await page.keyboard.press('End');
67+
await page.keyboard.type('&edited=1');
68+
await expect(playground.urlInput).toHaveValue(/&edited=1$/);
69+
70+
await closeAndReopen(page, playground, requestPage);
71+
72+
await expect(playground.urlInput).toHaveValue(/&edited=1$/);
73+
await playground.variable.hoverInputToken('host');
74+
await expect(playground.variable.value).toHaveText('https://edited.example.com');
75+
});
76+
77+
test('an edit made in the Environments view is kept after close and reopen', async ({
78+
page,
79+
playground,
80+
requestPage,
81+
envEditor
82+
}) => {
83+
const envTable = playground.keyValueTable;
84+
await playground.openEnvironments();
85+
await envEditor.selectEnvironment('Dev');
86+
await expect(envTable.nameInputs.first()).toHaveValue('host');
87+
await envTable.valueInputs.first().fill('https://from-env-view.example.com');
88+
89+
await closeAndReopen(page, playground, requestPage);
90+
91+
await playground.openEnvironments();
92+
await envEditor.selectEnvironment('Dev');
93+
await expect(envTable.valueInputs.first()).toHaveValue('https://from-env-view.example.com');
94+
});
95+
96+
test('a folder settings edit is kept after close and reopen', async ({ page, playground, requestPage }) => {
97+
await playground.openTreeItem(['Customers']);
98+
await playground.folderSettingsTab('variables').click();
99+
await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('folderScope');
100+
await playground.preRequestVars.valueInputs.first().fill('edited-folder');
101+
102+
await closeAndReopen(page, playground, requestPage);
103+
104+
await playground.openTreeItem(['Customers']);
105+
await playground.folderSettingsTab('variables').click();
106+
await expect(playground.preRequestVars.valueInputs.first()).toHaveValue('edited-folder');
107+
});
108+
109+
test('a collection settings edit is kept after close and reopen', async ({
110+
page,
111+
playground,
112+
requestPage,
113+
collectionSettings
114+
}) => {
115+
await playground.ensureSidebarOpen();
116+
await collectionSettings.open();
117+
await collectionSettings.openTab('variables');
118+
await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('apiVersion');
119+
await playground.preRequestVars.valueInputs.first().fill('2099-01');
120+
121+
await closeAndReopen(page, playground, requestPage);
122+
123+
await playground.ensureSidebarOpen();
124+
await collectionSettings.open();
125+
await collectionSettings.openTab('variables');
126+
await expect(playground.preRequestVars.valueInputs.first()).toHaveValue('2099-01');
127+
});
128+
129+
test('a value set by a script with bru.setEnvVar is kept after close and reopen', async ({
130+
page,
131+
playground,
132+
requestPage,
133+
responsePane
134+
}) => {
135+
await page.route('**/customers/**', (route) =>
136+
route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{}' })
137+
);
138+
139+
await playground.selectTab('scripts');
140+
await playground.selectScriptTab('post-response');
141+
await setEditorScript(
142+
page,
143+
playground.postResponseScriptEditor,
144+
'bru.setEnvVar(\'host\', \'https://from-script.example.com\');'
145+
);
146+
await responsePane.send();
147+
148+
await playground.variable.hoverInputToken('host');
149+
await expect(playground.variable.value).toHaveText('https://from-script.example.com');
150+
151+
await closeAndReopen(page, playground, requestPage);
152+
153+
await playground.variable.hoverInputToken('host');
154+
await expect(playground.variable.value).toHaveText('https://from-script.example.com');
155+
});
156+
157+
test('a reload restores the collection\'s original values', async ({ page, playground }) => {
158+
await playground.variable.hoverInputToken('unsetSecret');
159+
await playground.variable.editTo('typed-secret');
160+
await expect(playground.variable.value).toHaveText('*'.repeat('typed-secret'.length));
161+
await playground.variable.hoverInputToken('host');
162+
await expect(playground.variable.name).toHaveText('host');
163+
await playground.variable.editTo('https://edited.example.com');
164+
await expect(playground.variable.value).toHaveText('https://edited.example.com');
165+
166+
await page.reload();
167+
await playground.runner.waitFor({ state: 'visible' });
168+
await playground.openTreeItem(['Customers', 'Variables Demo']);
169+
await playground.view.waitFor({ state: 'visible' });
170+
await playground.envSwitcher.selectEnvironment('Dev');
171+
172+
await playground.variable.hoverInputToken('host');
173+
await expect(playground.variable.value).toHaveText('https://api.dev.example.com');
174+
await playground.variable.hoverInputToken('unsetSecret');
175+
await expect(playground.variable.name).toHaveText('unsetSecret');
176+
await expect(playground.variable.value).toHaveText('');
177+
});
178+
});

‎packages/bruno-api-docs/src/components/Playground/Playground.tsx‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import React, { Suspense, lazy, useEffect, useRef, useState } from 'react';
22
import { usePlaygroundUrlState, useIsMobilePhone } from '@/hooks';
3-
import { useAppDispatch } from '@/store/hooks';
4-
import { resetPlaygroundEnvironments } from '@/store/slices/playground';
53
import InlineDock from './docks/InlineDock/InlineDock';
64
import BottomSheetDock from './docks/BottomSheetDock/BottomSheetDock';
75
import ModalDock from './docks/ModalDock/ModalDock';
@@ -35,18 +33,13 @@ interface PlaygroundProps {
3533
}
3634

3735
const Playground: React.FC<PlaygroundProps> = ({ openNonce }) => {
38-
const dispatch = useAppDispatch();
3936
const { open, dock, requestSlug, exampleSlug, setDock, closePlayground } = usePlaygroundUrlState();
4037
const isPhone = useIsMobilePhone();
4138
// On a phone the playground is always the fullscreen MobileDock, and its
4239
// sidebar behaves like the inline dock's overlay. Feed that dock downstream so
4340
// the sidebar-open default and PlaygroundBody's overlay behaviour follow.
4441
const effectiveDock = isPhone ? 'inline' : dock;
4542

46-
// discard environment edits when the playground reopens
47-
useEffect(() => {
48-
if (open) dispatch(resetPlaygroundEnvironments());
49-
}, [open, dispatch]);
5043
// In the inline dock the sidebar is an overlay over the request/response, so it
5144
// starts closed (content full width) and opens on demand; bottom/modal show it
5245
// side by side, so it starts open. Reset to the dock's default when the dock changes.

‎packages/bruno-api-docs/src/store/slices/playground.spec.ts‎

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { describe, it, expect } from 'vitest';
22
import reducer, {
33
setPlaygroundCollection,
4-
updateCollectionEnvironments,
54
updatePlaygroundItem,
6-
resetPlaygroundEnvironments,
75
selectHydratedCollection,
86
selectPlaygroundCollection,
97
setViewMode,
8+
setSelectedItemId,
109
setSelectedExampleIndex,
1110
clearPlaygroundCollection,
1211
toggleFolderCollapse,
@@ -36,31 +35,21 @@ const envExternalSecrets = (store: ReturnType<typeof createOpenCollectionStore>)
3635
(selectHydratedCollection(store.getState())!.config!.environments![0].externalSecrets!
3736
.variables as unknown) as { name: string; value?: string; secretName?: string }[];
3837

39-
describe('resetPlaygroundEnvironments', () => {
40-
it('restores the original environments after an edit', () => {
38+
describe('playground edits', () => {
39+
it('keeps an environment edit through the navigation the playground dispatches on open', () => {
4140
const store = createOpenCollectionStore();
4241
store.dispatch(setPlaygroundCollection(makeCollection()));
42+
store.dispatch(setPlaygroundVariable({ scope: 'environment', envName: 'Dev', name: 'a', value: 'edited' }));
4343

44-
const edited = makeCollection();
45-
edited.config.environments[0].variables = [{ name: 'a', value: '1' }];
46-
store.dispatch(updateCollectionEnvironments(edited));
47-
expect(envVariables(store)).toHaveLength(1);
44+
// what PlaygroundBody dispatches when the playground opens on a view
45+
store.dispatch(setSelectedItemId(null));
46+
store.dispatch(setSelectedExampleIndex(null));
47+
store.dispatch(setViewMode('environments'));
48+
store.dispatch(setViewMode('playground'));
4849

49-
store.dispatch(resetPlaygroundEnvironments());
50-
expect(envVariables(store).map((v: any) => v.name)).toEqual(['a', 'b']);
51-
});
52-
53-
it('keeps the restore independent of later edits (cloned, not shared)', () => {
54-
const store = createOpenCollectionStore();
55-
store.dispatch(setPlaygroundCollection(makeCollection()));
56-
57-
store.dispatch(resetPlaygroundEnvironments());
58-
const edited = makeCollection();
59-
edited.config.environments[0].variables = [];
60-
store.dispatch(updateCollectionEnvironments(edited));
61-
62-
store.dispatch(resetPlaygroundEnvironments());
63-
expect(envVariables(store)).toHaveLength(2);
50+
expect(envVariables(store)).toEqual([{ name: 'a', value: 'edited' }, { name: 'b', value: '2' }]);
51+
expect(selectPlaygroundCollection(store.getState())!.config!.environments![0].variables![0])
52+
.toMatchObject({ value: 'edited' });
6453
});
6554
});
6655

‎packages/bruno-api-docs/src/store/slices/playground.ts‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2-
import { cloneDeep } from 'lodash-es';
32
import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
43
import type { Environment } from '@opencollection/types/config/environments';
54
import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item';
@@ -18,7 +17,6 @@ export type ViewMode = 'playground' | 'environments' | 'folder-settings' | 'coll
1817
export interface PlaygroundState {
1918
collection: OpenCollectionCollection | null;
2019
hydratedCollection: OpenCollectionCollection | null;
21-
pristineEnvironments: Environment[] | null;
2220
responses: Record<string, any>; // Store responses by item UUID
2321
// UI State
2422
viewMode: ViewMode;
@@ -32,7 +30,6 @@ export interface PlaygroundState {
3230
const initialState: PlaygroundState = {
3331
collection: null,
3432
hydratedCollection: null,
35-
pristineEnvironments: null,
3633
responses: {},
3734
// UI State
3835
viewMode: 'playground',
@@ -46,12 +43,6 @@ const initialState: PlaygroundState = {
4643
const readEnvironments = (collection: OpenCollectionCollection): Environment[] | null =>
4744
(collection as any).environments ?? collection.config?.environments ?? null;
4845

49-
const writeEnvironments = (collection: OpenCollectionCollection, environments: Environment[] | null): void => {
50-
if ((collection as any).environments !== undefined) (collection as any).environments = environments ?? undefined;
51-
if (collection.config) collection.config.environments = environments ?? undefined;
52-
else if (environments) collection.config = { environments };
53-
};
54-
5546
const findAndUpdateItemInCollection = (
5647
items: OpenCollectionItem[] | undefined,
5748
uuid: string,
@@ -133,13 +124,9 @@ const playgroundSlice = createSlice({
133124

134125
if (!action.payload) {
135126
state.hydratedCollection = null;
136-
state.pristineEnvironments = null;
137127
return;
138128
}
139129

140-
const envs = readEnvironments(action.payload);
141-
state.pristineEnvironments = envs ? cloneDeep(envs) : null;
142-
143130
const hydrated = hydrateWithUUIDs(action.payload);
144131

145132
// Preserve existing collapsed states from previous hydrated collection
@@ -154,7 +141,6 @@ const playgroundSlice = createSlice({
154141
clearPlaygroundCollection: (state: PlaygroundState) => {
155142
state.collection = null;
156143
state.hydratedCollection = null;
157-
state.pristineEnvironments = null;
158144
state.responses = {};
159145
state.selectedItemId = null;
160146
state.selectedExampleIndex = null;
@@ -261,11 +247,6 @@ const playgroundSlice = createSlice({
261247
});
262248
}
263249
},
264-
resetPlaygroundEnvironments: (state: PlaygroundState) => {
265-
const environments = state.pristineEnvironments ? cloneDeep(state.pristineEnvironments) : null;
266-
if (state.hydratedCollection) writeEnvironments(state.hydratedCollection, environments);
267-
if (state.collection) writeEnvironments(state.collection, environments);
268-
},
269250
setPlaygroundVariable: (
270251
state: PlaygroundState,
271252
action: PayloadAction<{
@@ -343,7 +324,6 @@ export const {
343324
updateCollectionEnvironments,
344325
applyScriptVariableChanges,
345326
updateFolderInCollection,
346-
resetPlaygroundEnvironments,
347327
setPlaygroundVariable,
348328
setResponseFormat,
349329
setShowResponsePreview

0 commit comments

Comments
 (0)