Files
bruno/tests/codeeditor-state/fold-persistence.spec.ts
2026-05-14 17:38:55 +05:30

867 lines
35 KiB
TypeScript

import { test, expect, Page, Locator } from '../../playwright';
import {
closeAllCollections,
createCollection,
createRequest,
openRequest,
selectRequestPaneTab,
selectResponsePaneTab,
sendRequestAndWaitForResponse
} from '../utils/page';
const ECHO_URL = 'http://localhost:8081/api/echo/json';
// JSON body that contains BOTH `{}` and `[]` foldable blocks. We send it to
// /echo so the response panel mirrors it back unchanged — same structure to
// fold on either side of the request/response boundary.
const SAMPLE_BODY = JSON.stringify(
{
user: {
id: 1,
name: 'Alice',
email: 'alice@example.com'
},
items: [
{ sku: 'A-001', qty: 1 },
{ sku: 'A-002', qty: 2 },
{ sku: 'A-003', qty: 3 }
],
meta: {
createdAt: '2024-01-01T00:00:00Z',
tags: ['x', 'y', 'z']
}
},
null,
2
);
// A larger body with five foldable blocks each carrying a *different* item
// count, so we can assert on `↤4↦`, `↤5↦`, `↤3↦`, `↤6↦`, `↤2↦` independently.
// Line numbers (0-indexed) for the OPENING brace/bracket of each block:
// 1 → user object (4 keys) → ↤4↦
// 7 → items array (5 elems) → ↤5↦ (each elem is a 2-key object)
// 29 → address object (3 keys) → ↤3↦
// 34 → tags array (6 strings) → ↤6↦
// 42 → meta object (2 keys) → ↤2↦
const LARGE_BODY = JSON.stringify(
{
user: {
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'admin'
},
items: [
{ sku: 'A-001', qty: 1 },
{ sku: 'A-002', qty: 2 },
{ sku: 'A-003', qty: 3 },
{ sku: 'A-004', qty: 4 },
{ sku: 'A-005', qty: 5 }
],
address: {
city: 'NYC',
zip: '10001',
country: 'US'
},
tags: ['x', 'y', 'z', 'a', 'b', 'c'],
meta: {
createdAt: '2024-01-01',
version: 2
}
},
null,
2
);
// --- Helpers --------------------------------------------------------------
const cmFor = (page: Page, scope: Locator) => scope.locator('.CodeMirror').first();
const setBodyContent = async (page: Page, value: string) => {
await cmFor(page, page.locator('.request-pane')).evaluate(
(el, v) => (el as any).CodeMirror?.setValue(v),
value
);
};
const selectBodyMode = async (page: Page, mode: 'JSON' | 'XML' | 'Text') => {
await page.locator('.body-mode-selector').click();
await page.locator('.dropdown-item').filter({ hasText: mode }).click();
};
// Fold the block opening on the given line number (0-indexed). Driven through
// CM's API so we don't depend on gutter pixel positions.
const foldLine = async (cm: Locator, line: number) =>
cm.evaluate((el, l) => {
const editor = (el as any).CodeMirror;
if (!editor) return;
// Position at end of the line — brace-fold scans backward to find the {/[
const lineText = editor.getLine(l) ?? '';
editor.foldCode({ line: l, ch: lineText.length }, null, 'fold');
}, line);
// --- Tests ----------------------------------------------------------------
test.describe('CodeEditor — fold state persists across tab switches', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('Body editor: folded {} block survives Body → Headers → Body', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-body-curly', await createTmpDir('fold-body-curly'));
await createRequest(page, 'echo-curly', 'fold-body-curly', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-body-curly', 'echo-curly');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const bodyCm = cmFor(page, page.locator('.request-pane'));
// Fold the `user: {` block on line 1. The `user` object has 3 keys
// (id, name, email), so the widget renders as `↤3↦`.
await foldLine(bodyCm, 1);
await expect(bodyCm.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(bodyCm.getByText('↤3↦')).toHaveCount(1);
// Sub-tab switch — Headers unmounts the body editor in Bruno's request pane.
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
// Fold widget reappears on return — same count and same item-count widget.
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(restored.getByText('↤3↦')).toHaveCount(1);
});
test('Body editor: folded [] block survives Body → Headers → Body', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-body-array', await createTmpDir('fold-body-array'));
await createRequest(page, 'echo-array', 'fold-body-array', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-body-array', 'echo-array');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const bodyCm = cmFor(page, page.locator('.request-pane'));
// The `items: [` block — first array opening in SAMPLE_BODY (line 6).
// The array has 3 elements, so the widget shows `↤3↦`.
await foldLine(bodyCm, 6);
await expect(bodyCm.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(bodyCm.getByText('↤3↦')).toHaveCount(1);
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(restored.getByText('↤3↦')).toHaveCount(1);
});
test('Body editor: nested {} + [] folds both restore on return', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-body-nested', await createTmpDir('fold-body-nested'));
await createRequest(page, 'echo-nested', 'fold-body-nested', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-body-nested', 'echo-nested');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const bodyCm = cmFor(page, page.locator('.request-pane'));
// Fold both an object and an array. user has 3 keys, items has 3 elements,
// so both widgets show `↤3↦` — total of two `↤3↦` markers in the editor.
await foldLine(bodyCm, 1); // user: {
await foldLine(bodyCm, 6); // items: [
await expect(bodyCm.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(bodyCm.getByText('↤3↦')).toHaveCount(2);
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(restored.getByText('↤3↦')).toHaveCount(2);
});
test('Response editor: folded {} block survives response sub-tab switch', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-response-curly', await createTmpDir('fold-response-curly'));
await createRequest(page, 'echo-resp-curly', 'fold-response-curly', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-response-curly', 'echo-resp-curly');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
await sendRequestAndWaitForResponse(page);
const responseCm = cmFor(page, page.locator('[data-testid="response-pane"]'));
// Fold the same `user: {` block on the response side. /echo mirrors the
// body, so line numbers and item counts match (3 keys → `↤3↦`).
await foldLine(responseCm, 1);
await expect(responseCm.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(responseCm.getByText('↤3↦')).toHaveCount(1);
// Switch to a different response sub-tab and back — this remounts the
// response code editor.
await selectResponsePaneTab(page, 'Headers');
await selectResponsePaneTab(page, 'Response');
const restored = cmFor(page, page.locator('[data-testid="response-pane"]'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(restored.getByText('↤3↦')).toHaveCount(1);
});
test('Response editor: folded [] block survives response sub-tab switch', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-response-array', await createTmpDir('fold-response-array'));
await createRequest(page, 'echo-resp-array', 'fold-response-array', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-response-array', 'echo-resp-array');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
await sendRequestAndWaitForResponse(page);
const responseCm = cmFor(page, page.locator('[data-testid="response-pane"]'));
// items array has 3 elements → widget shows `↤3↦`.
await foldLine(responseCm, 6);
await expect(responseCm.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(responseCm.getByText('↤3↦')).toHaveCount(1);
await selectResponsePaneTab(page, 'Headers');
await selectResponsePaneTab(page, 'Response');
const restored = cmFor(page, page.locator('[data-testid="response-pane"]'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(1);
await expect(restored.getByText('↤3↦')).toHaveCount(1);
});
test('Folds in body editor survive a parent tab switch (different request)', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-parent-tab', await createTmpDir('fold-parent-tab'));
await createRequest(page, 'echo-a', 'fold-parent-tab', { url: ECHO_URL, method: 'POST' });
await createRequest(page, 'echo-b', 'fold-parent-tab', { url: ECHO_URL, method: 'POST' });
// Open echo-a from the sidebar (createRequest doesn't auto-open as a tab).
await openRequest(page, 'fold-parent-tab', 'echo-a');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const cmA = cmFor(page, page.locator('.request-pane'));
await foldLine(cmA, 1); // user: { (3 keys)
await foldLine(cmA, 6); // items: [ (3 elements)
await expect(cmA.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(cmA.getByText('↤3↦')).toHaveCount(2);
// Switch to echo-b — opens it as a parent tab.
await openRequest(page, 'fold-parent-tab', 'echo-b');
// Switch back to echo-a from the sidebar.
await openRequest(page, 'fold-parent-tab', 'echo-a');
// Folds restored — both `↤3↦` widgets reappear.
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(restored.getByText('↤3↦')).toHaveCount(2);
});
test('Two requests do not share fold state', async ({ page, createTmpDir }) => {
await createCollection(page, 'fold-isolation', await createTmpDir('fold-isolation'));
await createRequest(page, 'req-a', 'fold-isolation', { url: ECHO_URL, method: 'POST' });
await createRequest(page, 'req-b', 'fold-isolation', { url: ECHO_URL, method: 'POST' });
// req-a: body with two folds (user 3 keys + items 3 elements).
await openRequest(page, 'fold-isolation', 'req-a');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const cmReqA = cmFor(page, page.locator('.request-pane'));
await foldLine(cmReqA, 1);
await foldLine(cmReqA, 6);
await expect(cmReqA.getByText('↤3↦')).toHaveCount(2);
// req-b: same body, no folds.
await openRequest(page, 'fold-isolation', 'req-b');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
// Editor for req-b shows the body unfolded — zero `↤3↦` widgets, no fold markers.
const cmReqB = cmFor(page, page.locator('.request-pane'));
await expect(cmReqB.locator('.CodeMirror-foldmarker')).toHaveCount(0);
await expect(cmReqB.getByText('↤3↦')).toHaveCount(0);
// Switch back to req-a from the sidebar — its folds are still there.
await openRequest(page, 'fold-isolation', 'req-a');
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(restored.getByText('↤3↦')).toHaveCount(2);
});
});
test.describe('CodeEditor — undo (Cmd-Z) survives a tab switch', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
// Helper used by the multi-tab undo tests below. Focuses the body editor and
// moves the cursor to a known spot before typing, so the keystrokes don't
// interleave into existing JSON.
const focusEndOfBody = async (page: Page) => {
const cm = cmFor(page, page.locator('.request-pane'));
await cm.evaluate((el) => {
const editor = (el as any).CodeMirror;
editor.focus();
const lastLine = editor.lastLine();
editor.setCursor({ line: lastLine, ch: 0 });
});
return cm;
};
const undoShortcut = process.platform === 'darwin' ? 'Meta+Z' : 'Control+Z';
const redoShortcut = process.platform === 'darwin' ? 'Meta+Shift+Z' : 'Control+Shift+Z';
test('Editing the body, switching sub-tab, returning, and pressing undo reverts the edit', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'undo-body', await createTmpDir('undo-body'));
await createRequest(page, 'echo-undo', 'undo-body', { url: ECHO_URL, method: 'POST' });
await openRequest(page, 'undo-body', 'echo-undo');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const bodyCm = cmFor(page, page.locator('.request-pane'));
// Type a unique sentinel as a "user edit". Position the cursor at the end
// first so the typed text doesn't interleave into existing JSON.
await bodyCm.evaluate((el) => {
const editor = (el as any).CodeMirror;
editor.focus();
const lastLine = editor.lastLine();
editor.setCursor({ line: lastLine, ch: 0 });
});
await page.keyboard.type('// SENTINEL_FROM_TEST\n');
await expect(bodyCm).toContainText('SENTINEL_FROM_TEST');
// Switch sub-tab and back — the editor unmounts and remounts.
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
// Bring focus back to the editor and undo. Use the appropriate platform
// shortcut. CodeMirror's history was rebuilt from localStorage on remount.
const cmAfter = cmFor(page, page.locator('.request-pane'));
await cmAfter.click();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Z' : 'Control+Z');
// The sentinel is gone — undo replayed the saved history.
await expect(cmAfter).not.toContainText('SENTINEL_FROM_TEST');
});
test('Body content + undo history survive walking through Headers / Params / Vars / Script', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'undo-walk', await createTmpDir('undo-walk'));
await createRequest(page, 'echo-walk', 'undo-walk', { url: ECHO_URL, method: 'POST' });
await openRequest(page, 'undo-walk', 'echo-walk');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
await focusEndOfBody(page);
await page.keyboard.type('// SENTINEL_WALK\n');
// After every sub-tab visit, return to Body and assert the marker is still
// there. This proves no individual switch silently discards content.
for (const subTab of ['Headers', 'Params', 'Vars', 'Script'] as const) {
await selectRequestPaneTab(page, subTab);
await selectRequestPaneTab(page, 'Body');
await expect(cmFor(page, page.locator('.request-pane')))
.toContainText('SENTINEL_WALK');
}
// Undo at the very end — the saved history must include the typed line
// even after four remounts.
const cmAfter = cmFor(page, page.locator('.request-pane'));
await cmAfter.click();
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_WALK');
});
test('Cmd-Z reverts an edit when applied after each sub-tab return', async ({
page,
createTmpDir
}) => {
// Independently verify undo for every sub-tab — type a unique sentinel,
// visit one sub-tab and return, undo, assert clean. This catches a sub-tab
// whose remount path corrupts the history while others stay healthy.
await createCollection(page, 'undo-each-tab', await createTmpDir('undo-each-tab'));
await createRequest(page, 'echo-each', 'undo-each-tab', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'undo-each-tab', 'echo-each');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
const subTabs = ['Headers', 'Params', 'Vars', 'Script'] as const;
for (const subTab of subTabs) {
const sentinel = `// SENTINEL_${subTab.toUpperCase()}`;
await focusEndOfBody(page);
await page.keyboard.type(`${sentinel}\n`);
await expect(cmFor(page, page.locator('.request-pane'))).toContainText(sentinel);
await selectRequestPaneTab(page, subTab);
await selectRequestPaneTab(page, 'Body');
// Sentinel still visible after the round-trip.
const cmAfter = cmFor(page, page.locator('.request-pane'));
await expect(cmAfter).toContainText(sentinel);
// Undo just the line we added — restoring the editor to clean SAMPLE_BODY.
await cmAfter.click();
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText(sentinel);
}
});
test('Cmd-Shift-Z (redo) restores a previously undone edit across a tab switch', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'undo-redo', await createTmpDir('undo-redo'));
await createRequest(page, 'echo-redo', 'undo-redo', { url: ECHO_URL, method: 'POST' });
await openRequest(page, 'undo-redo', 'echo-redo');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
await focusEndOfBody(page);
await page.keyboard.type('// SENTINEL_REDO\n');
const cm = cmFor(page, page.locator('.request-pane'));
await expect(cm).toContainText('SENTINEL_REDO');
// Round-trip the editor through a sub-tab, then undo.
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
let cmAfter = cmFor(page, page.locator('.request-pane'));
await cmAfter.click();
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_REDO');
// Redo brings the line back. This proves the saved history kept the
// `undone` stack — not just the `done` stack.
await page.keyboard.press(redoShortcut);
await expect(cmAfter).toContainText('SENTINEL_REDO');
// One more sub-tab round-trip — redo state must persist through it.
await selectRequestPaneTab(page, 'Vars');
await selectRequestPaneTab(page, 'Body');
cmAfter = cmFor(page, page.locator('.request-pane'));
await expect(cmAfter).toContainText('SENTINEL_REDO');
// Final undo cleans up.
await cmAfter.click();
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_REDO');
});
test('Multi-step undo preserves edit order across a tab switch', async ({
page,
createTmpDir
}) => {
// Type three discrete edits, switch sub-tab, switch back, then undo three
// times — each undo must remove the most-recent sentinel, in reverse order.
//
// We bypass `page.keyboard.type` here on purpose: CodeMirror groups rapid
// typed edits sharing the default `+input` origin into a single undo step
// (controlled by `historyEventDelay`, default 1250ms). To force three
// distinct undo entries we call `replaceRange` directly with `*`-prefixed
// origins — CM5 never merges history entries whose origin starts with `*`.
await createCollection(page, 'undo-multi', await createTmpDir('undo-multi'));
await createRequest(page, 'echo-multi', 'undo-multi', { url: ECHO_URL, method: 'POST' });
await openRequest(page, 'undo-multi', 'echo-multi');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
// Insert all three sentinels with three distinct CM history entries
// (preserved by the `*`-prefixed origins) while ensuring the React
// wrapper sees only ONE onChange. The wrapper's `_onEdit` listener
// dispatches `updateRequestBody` on every `change` event; on slow
// runners three rapid dispatches don't always batch, and an
// intermediate re-render with a stale `props.value` can trigger
// `componentDidUpdate`'s `setValue(props.value)` path, wiping a
// just-inserted sentinel. We detach `change` listeners for the
// duration of the three `replaceRange`s, restore them after, then
// fire ONE synthetic change so the wrapper dispatches once with the
// final value — leaving editor content and redux state in sync before
// any downstream tab-switch reads from `props.value`.
await cmFor(page, page.locator('.request-pane')).evaluate((el) => {
const editor = (el as any).CodeMirror;
editor.focus();
const doc = editor.getDoc();
// CM5 stores listeners in an internal `_handlers` map on the editor.
// Save and clear the `change` slot, do the inserts, restore, then
// fire one synthetic change to flush the final value through onEdit.
const handlersSlot = editor._handlers || (editor._handlers = {});
const savedChange = (handlersSlot.change || []).slice();
handlersSlot.change = [];
try {
const append = (sentinel: string, originSuffix: string) => {
const lastLine = doc.lastLine();
const lastLineLen = doc.getLine(lastLine).length;
doc.replaceRange(
`\n${sentinel}`,
{ line: lastLine, ch: lastLineLen },
undefined,
`*${originSuffix}`
);
};
append('// SENTINEL_ONE', 'sentinel-1');
append('// SENTINEL_TWO', 'sentinel-2');
append('// SENTINEL_THREE', 'sentinel-3');
} finally {
handlersSlot.change = savedChange;
}
// Mirror real typing: a user's cursor lands at the end of the text
// they just typed, and CM5 scrolls the cursor into view. Without
// this, the viewport stays parked at the top, and on shorter
// viewports (e.g. macOS CI) the last appended line falls outside
// the rendered range — CM virtualizes off-viewport lines, so the
// sentinel is in the doc but not in the DOM, and `toContainText`
// can't see it.
const last = doc.lastLine();
editor.setCursor({ line: last, ch: doc.getLine(last).length });
// `_onEdit` only reads `editor.getValue()`; the change descriptor
// arg is unused, so passing null is safe.
savedChange.forEach((handler: (cm: unknown, change: unknown) => void) => {
handler(editor, null);
});
});
const cm = cmFor(page, page.locator('.request-pane'));
await expect(cm).toContainText('SENTINEL_ONE');
await expect(cm).toContainText('SENTINEL_TWO');
await expect(cm).toContainText('SENTINEL_THREE');
await selectRequestPaneTab(page, 'Script');
await selectRequestPaneTab(page, 'Body');
const cmAfter = cmFor(page, page.locator('.request-pane'));
await cmAfter.click();
// Undo step 1: only SENTINEL_THREE is removed.
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_THREE');
await expect(cmAfter).toContainText('SENTINEL_TWO');
await expect(cmAfter).toContainText('SENTINEL_ONE');
// Undo step 2: SENTINEL_TWO is removed.
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_TWO');
await expect(cmAfter).toContainText('SENTINEL_ONE');
// Undo step 3: SENTINEL_ONE is removed — back to clean SAMPLE_BODY.
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_ONE');
});
test('Undo persists across switching to a different request and back', async ({
page,
createTmpDir
}) => {
// Parent-tab switch (different request) is a stronger remount path than
// sub-tab switches — verify undo history survives that too.
await createCollection(page, 'undo-parent', await createTmpDir('undo-parent'));
await createRequest(page, 'echo-undo-a', 'undo-parent', { url: ECHO_URL, method: 'POST' });
await createRequest(page, 'echo-undo-b', 'undo-parent', { url: ECHO_URL, method: 'POST' });
// Set up echo-undo-a with an edit.
await openRequest(page, 'undo-parent', 'echo-undo-a');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, SAMPLE_BODY);
await focusEndOfBody(page);
await page.keyboard.type('// SENTINEL_PARENT\n');
await expect(cmFor(page, page.locator('.request-pane'))).toContainText('SENTINEL_PARENT');
// Bounce through echo-undo-b and back.
await openRequest(page, 'undo-parent', 'echo-undo-b');
await openRequest(page, 'undo-parent', 'echo-undo-a');
const cmAfter = cmFor(page, page.locator('.request-pane'));
await expect(cmAfter).toContainText('SENTINEL_PARENT');
// Undo on the rebuilt editor still works.
await cmAfter.click();
await page.keyboard.press(undoShortcut);
await expect(cmAfter).not.toContainText('SENTINEL_PARENT');
});
});
test.describe('CodeEditor — varied {} / [] folds with distinct widget counts', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('Body: all five foldable blocks (4/5/3/6/2 keys) restore with correct widget counts', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-varied-body', await createTmpDir('fold-varied-body'));
await createRequest(page, 'echo-varied', 'fold-varied-body', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-varied-body', 'echo-varied');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, LARGE_BODY);
const bodyCm = cmFor(page, page.locator('.request-pane'));
// Fold each top-level block in order. Each one has a unique item count.
await foldLine(bodyCm, 1); // user → ↤4↦
await foldLine(bodyCm, 7); // items → ↤5↦
await foldLine(bodyCm, 29); // address → ↤3↦
await foldLine(bodyCm, 34); // tags → ↤6↦
await foldLine(bodyCm, 42); // meta → ↤2↦
// Each unique count should appear exactly once in the editor.
await expect(bodyCm.locator('.CodeMirror-foldmarker')).toHaveCount(5);
await expect(bodyCm.getByText('↤4↦')).toHaveCount(1);
await expect(bodyCm.getByText('↤5↦')).toHaveCount(1);
await expect(bodyCm.getByText('↤3↦')).toHaveCount(1);
await expect(bodyCm.getByText('↤6↦')).toHaveCount(1);
await expect(bodyCm.getByText('↤2↦')).toHaveCount(1);
// Sub-tab round-trip — every fold and its count must reappear.
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(5);
await expect(restored.getByText('↤4↦')).toHaveCount(1);
await expect(restored.getByText('↤5↦')).toHaveCount(1);
await expect(restored.getByText('↤3↦')).toHaveCount(1);
await expect(restored.getByText('↤6↦')).toHaveCount(1);
await expect(restored.getByText('↤2↦')).toHaveCount(1);
});
test('Body: a random subset of folds restores intact (only items + tags)', async ({
page,
createTmpDir
}) => {
// Pick a non-trivial subset (the array blocks only) to confirm we don't
// accidentally over-restore — only the folds the user made should come back.
await createCollection(page, 'fold-subset-body', await createTmpDir('fold-subset-body'));
await createRequest(page, 'echo-subset', 'fold-subset-body', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-subset-body', 'echo-subset');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, LARGE_BODY);
const bodyCm = cmFor(page, page.locator('.request-pane'));
await foldLine(bodyCm, 7); // items → ↤5↦
await foldLine(bodyCm, 34); // tags → ↤6↦
await expect(bodyCm.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(bodyCm.getByText('↤5↦')).toHaveCount(1);
await expect(bodyCm.getByText('↤6↦')).toHaveCount(1);
// Object blocks were not folded, their counts must not appear as widgets.
await expect(bodyCm.getByText('↤4↦')).toHaveCount(0);
await expect(bodyCm.getByText('↤3↦')).toHaveCount(0);
await expect(bodyCm.getByText('↤2↦')).toHaveCount(0);
await selectRequestPaneTab(page, 'Headers');
await selectRequestPaneTab(page, 'Body');
const restored = cmFor(page, page.locator('.request-pane'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(restored.getByText('↤5↦')).toHaveCount(1);
await expect(restored.getByText('↤6↦')).toHaveCount(1);
await expect(restored.getByText('↤4↦')).toHaveCount(0);
await expect(restored.getByText('↤3↦')).toHaveCount(0);
await expect(restored.getByText('↤2↦')).toHaveCount(0);
});
});
test.describe('CodeEditor — response folds survive Timeline and Headers tab switches', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('Response → Timeline → Response preserves multiple varied folds', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-resp-timeline', await createTmpDir('fold-resp-timeline'));
await createRequest(page, 'echo-resp-timeline', 'fold-resp-timeline', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-resp-timeline', 'echo-resp-timeline');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, LARGE_BODY);
await sendRequestAndWaitForResponse(page);
// Fold three blocks of different shapes on the response side. /echo
// mirrors the body so line numbers match.
const responseCm = cmFor(page, page.locator('[data-testid="response-pane"]'));
await foldLine(responseCm, 1); // user → ↤4↦
await foldLine(responseCm, 7); // items → ↤5↦
await foldLine(responseCm, 34); // tags → ↤6↦
await expect(responseCm.locator('.CodeMirror-foldmarker')).toHaveCount(3);
await expect(responseCm.getByText('↤4↦')).toHaveCount(1);
await expect(responseCm.getByText('↤5↦')).toHaveCount(1);
await expect(responseCm.getByText('↤6↦')).toHaveCount(1);
// Switch to Timeline (no editor on this tab — the response editor unmounts)
// and back. State must round-trip cleanly through localStorage.
await selectResponsePaneTab(page, 'Timeline');
await selectResponsePaneTab(page, 'Response');
const restored = cmFor(page, page.locator('[data-testid="response-pane"]'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(3);
await expect(restored.getByText('↤4↦')).toHaveCount(1);
await expect(restored.getByText('↤5↦')).toHaveCount(1);
await expect(restored.getByText('↤6↦')).toHaveCount(1);
});
test('Response → Headers → Response preserves multiple varied folds', async ({
page,
createTmpDir
}) => {
await createCollection(page, 'fold-resp-headers', await createTmpDir('fold-resp-headers'));
await createRequest(page, 'echo-resp-headers', 'fold-resp-headers', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-resp-headers', 'echo-resp-headers');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, LARGE_BODY);
await sendRequestAndWaitForResponse(page);
const responseCm = cmFor(page, page.locator('[data-testid="response-pane"]'));
await foldLine(responseCm, 7); // items → ↤5↦
await foldLine(responseCm, 29); // address → ↤3↦
await foldLine(responseCm, 42); // meta → ↤2↦
await expect(responseCm.locator('.CodeMirror-foldmarker')).toHaveCount(3);
await expect(responseCm.getByText('↤5↦')).toHaveCount(1);
await expect(responseCm.getByText('↤3↦')).toHaveCount(1);
await expect(responseCm.getByText('↤2↦')).toHaveCount(1);
await selectResponsePaneTab(page, 'Headers');
await selectResponsePaneTab(page, 'Response');
const restored = cmFor(page, page.locator('[data-testid="response-pane"]'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(3);
await expect(restored.getByText('↤5↦')).toHaveCount(1);
await expect(restored.getByText('↤3↦')).toHaveCount(1);
await expect(restored.getByText('↤2↦')).toHaveCount(1);
});
test('Response folds survive Response → Timeline → Headers → Response chain', async ({
page,
createTmpDir
}) => {
// Walk through several response sub-tabs without folding back the editor —
// each transition triggers an unmount/mount of the response editor, so
// every step exercises the persistence layer.
await createCollection(page, 'fold-resp-chain', await createTmpDir('fold-resp-chain'));
await createRequest(page, 'echo-resp-chain', 'fold-resp-chain', {
url: ECHO_URL,
method: 'POST'
});
await openRequest(page, 'fold-resp-chain', 'echo-resp-chain');
await selectRequestPaneTab(page, 'Body');
await selectBodyMode(page, 'JSON');
await setBodyContent(page, LARGE_BODY);
await sendRequestAndWaitForResponse(page);
const responseCm = cmFor(page, page.locator('[data-testid="response-pane"]'));
await foldLine(responseCm, 1); // user → ↤4↦
await foldLine(responseCm, 34); // tags → ↤6↦
await expect(responseCm.getByText('↤4↦')).toHaveCount(1);
await expect(responseCm.getByText('↤6↦')).toHaveCount(1);
// Cycle through Timeline → Headers → Response. The editor remounts on each
// return, so this catches any cumulative state-loss bug.
await selectResponsePaneTab(page, 'Timeline');
await selectResponsePaneTab(page, 'Headers');
await selectResponsePaneTab(page, 'Response');
const restored = cmFor(page, page.locator('[data-testid="response-pane"]'));
await expect(restored.locator('.CodeMirror-foldmarker')).toHaveCount(2);
await expect(restored.getByText('↤4↦')).toHaveCount(1);
await expect(restored.getByText('↤6↦')).toHaveCount(1);
});
});