qwen-code/packages/web-shell/client/e2e/web-shell.split-persist.spec.ts
Shaojin Wen adf2caea39
feat(web-shell): persist the split view across refresh, per tab (#7136)
The split view (2+ sessions side by side) was lost on every refresh: its
pane set lived only in React state, and the one-shot ?split= deep link is
consumed on load. Persist the live session set to sessionStorage while the
split is the active view, and restore it on load when no ?split= deep link
is present, so a refresh brings the split back.

sessionStorage (not localStorage) is deliberate: it is scoped per browser
tab, so a split opened in its own tab and the in-window split never clobber
each other, and a fresh unrelated tab restores nothing — while still
surviving a refresh of the same tab. The ?split= URL stays the shareable,
cross-tab channel.

Only an explicit close (the split's back button) clears the persisted set;
detours to a single session keep it, so the split is treated as the user's
lasting context until they close it. Controlled hosts (which own their split
lifecycle) never auto-persist or auto-restore.

Co-authored-by: wenshao <wenshao@example.com>
2026-07-18 08:29:23 +00:00

137 lines
4.5 KiB
TypeScript

import { expect, test, type Page, type TestInfo } from '@playwright/test';
import {
createWebShellDaemonScenario,
installMockDaemon,
type MockDaemonController,
type WebShellDaemonScenario,
} from './utils/mockDaemon';
const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e';
const MAIN_SESSION = 'split-main-session';
const SESSION_A = 'split-session-a';
const SESSION_B = 'split-session-b';
const STORAGE_KEY = 'qwen-webshell-split-sessions';
function createSplitScenario(): WebShellDaemonScenario {
const at = '2026-07-03T00:00:00.000Z';
return createWebShellDaemonScenario({
workspaceCwd: WORKSPACE_CWD,
sessionId: MAIN_SESSION,
sessions: [
{
sessionId: MAIN_SESSION,
workspaceCwd: WORKSPACE_CWD,
createdAt: at,
updatedAt: at,
displayName: 'Main Session',
clientCount: 1,
hasActivePrompt: false,
},
{
sessionId: SESSION_A,
workspaceCwd: WORKSPACE_CWD,
createdAt: at,
updatedAt: at,
displayName: 'Session A',
clientCount: 0,
hasActivePrompt: false,
},
{
sessionId: SESSION_B,
workspaceCwd: WORKSPACE_CWD,
createdAt: at,
updatedAt: at,
displayName: 'Session B',
clientCount: 0,
hasActivePrompt: false,
},
],
});
}
async function installScenario(
page: Page,
scenario: WebShellDaemonScenario,
testInfo: TestInfo,
): Promise<MockDaemonController> {
return installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
}
test('restores the split across a reload and isolates it per tab @smoke', async ({
page,
context,
}, testInfo) => {
// Wide viewport so the split stays unfolded (it folds below the large-screen
// breakpoint).
await page.setViewportSize({ width: 1440, height: 900 });
const scenario = createSplitScenario();
await installScenario(page, scenario, testInfo);
// Open the split via the deep link — the exact URL "open in new tab" produces
// (path reset to `/`, sessions in `?split=`).
await page.goto(`/?split=${SESSION_A},${SESSION_B}`);
const split = page.locator('[data-testid="split-view"]');
await expect(split).toBeVisible();
await expect(page.locator('[data-testid="chat-pane"]')).toHaveCount(2);
// The session set lands in per-tab storage…
await expect
.poll(async () =>
page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY),
)
.toBe(JSON.stringify([SESSION_A, SESSION_B]));
// …and the one-shot deep-link param is consumed so a bookmark isn't sticky.
await expect.poll(async () => new URL(page.url()).search).toBe('');
// Reload (URL is now bare `/`): the split comes back from storage.
await page.reload();
await expect(page.locator('[data-testid="split-view"]')).toBeVisible();
await expect(page.locator('[data-testid="chat-pane"]')).toHaveCount(2);
// A brand-new tab has its own sessionStorage, so it must NOT inherit tab 1's
// split. (If persistence used localStorage, this tab would wrongly reopen it.)
const page2 = await context.newPage();
await page2.setViewportSize({ width: 1440, height: 900 });
await installScenario(page2, scenario, testInfo);
await page2.goto(`/session/${MAIN_SESSION}`);
await expect(page2.locator('[data-web-shell-root]')).toBeVisible();
await expect(page2.locator('[data-testid="split-view"]')).toHaveCount(0);
});
test('leaving the split clears storage so a refresh does not restore it', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
const scenario = createSplitScenario();
await installScenario(page, scenario, testInfo);
await page.goto(`/?split=${SESSION_A},${SESSION_B}`);
await expect(page.locator('[data-testid="split-view"]')).toBeVisible();
await expect
.poll(async () =>
page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY),
)
.toBe(JSON.stringify([SESSION_A, SESSION_B]));
// Leave via the split's back button.
await page
.locator('[data-testid="split-view"] header button')
.first()
.click();
await expect(page.locator('[data-testid="split-view"]')).toHaveCount(0);
await expect
.poll(async () =>
page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY),
)
.toBeNull();
// A refresh now lands on the normal view, not the split.
await page.reload();
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
await expect(page.locator('[data-testid="split-view"]')).toHaveCount(0);
});