open-design/e2e/ui/design-files-view-state-persist.test.ts
Patrick A 9146dc1c57
fix(web): persist design files view state across navigation (#2303)
* fix(web): persist design-files view state across navigation

pageSize, sortKey, sortDir, and kindFilter reset on every navigation
because DesignFilesPanel remounts via key={projectId}. Persist them to
localStorage under od:design-files:view-state:v1:<projectId> so each
project's view prefs survive tab-switching.

- Read persisted state via lazy useState initializers (SSR-safe try/catch)
- Write back in a single useEffect keyed on all four values
- Scoped per-project so proj-a settings never bleed into proj-b
- Schema-guarded: invalid/missing fields fall through to defaults
- Red spec: apps/web/tests/components/DesignFilesPanel.view-state-persist.test.tsx

* fix(web): address review feedback on view-state persistence

- Add typeof window guard in readViewState for explicit SSR safety
- Consolidate 4 separate localStorage reads into a single useRef read at
  mount time; each lazy useState initializer now reads from savedViewState.current
  instead of re-parsing localStorage independently

* fix(web): harden design-files view-state persistence

- Validate restored kindFilter values against the current ProjectFileKind
  union via isProjectFileKind() so stale stored values from a prior schema
  are dropped silently instead of being cast unchecked.

- Introduce DEFAULT_SORT_KEY/SORT_DIR/PAGE_SIZE constants so the useState
  initialisers and the new validation guard share a single source of truth.

- Add viewStateHasMounted ref to skip the first-render write in the persist
  useEffect. Without this guard every project the user visits accumulates a
  default-value entry in localStorage on mount, growing stale-key garbage
  unboundedly and making future field additions silently inject defaults into
  every existing entry.

- Harden kindFilter test: replace the silent early-return-on-missing-trigger
  with expect(filterTrigger).not.toBeNull() so a render failure surfaces as
  a real test failure rather than a passing no-op.

* test(e2e): design files view state persists across navigation and reload

Adds a Playwright UI smoke test in e2e/ui/ that exercises the three key
guarantees of the view-state persistence fix:

  (a) Tab-away / tab-back: navigating to a file tab and returning remounts
      DesignFilesPanel (conditionally rendered); all four prefs (sortKey,
      sortDir, pageSize, kindFilter) are restored from localStorage.

  (b) Hard reload: localStorage survives page.reload(); prefs are intact on
      the next mount.

  (c) Per-project key isolation: a second project starts with defaults and
      does not inherit values from the first project's localStorage entry.

The test uses OD_PORT=18011 / OD_WEB_PORT=18012 to avoid port conflicts with
the default development ports.

Also fixes a race in DesignFilesPanel: the stale-kind cleanup useEffect was
running against an empty availableKinds set before the async file list arrived
on mount, which cleared a kindFilter correctly restored from localStorage.
Guard added: skip the cleanup when availableKinds is empty.

Red on origin/main (no persistence logic exists there); green on this branch.

* fix(e2e): address code-reviewer feedback on view-state-persist test

- Add data-testid='df-page-size-select' to per-page <select> in
  DesignFilesPanel (W2: decouple test from i18n string 'Show')
- Add StrictMode comment to viewStateHasMounted guard explaining
  the dev-mode double-write behaviour (W1: document the invariant)
- Switch nav-away from dblclick to single-click + Open button,
  matching the pattern used in app-design-files.test.ts (W4)
- Raise timeout from 60s to 90s for cold CI runners (W3)
- Unify seedTextFile/seedPngFile into shared seedFile helper (N3)
- Add home-hero-input assertion in gotoEntryHome (N2)
- Switch waitForPageSizeSelect to use data-testid (W2)

* test(e2e): split design-files persist into nav, reload, and per-project scenarios

* fix(web): tighten isPageSize to discrete option set, add invalid-value regression test

* fix(web): isolate DesignFilesPanel.test.tsx from persisted view-state key
2026-05-30 03:39:27 +00:00

361 lines
14 KiB
TypeScript

/**
* Verifies that the DesignFilesPanel view state (sortKey, sortDir, pageSize,
* kindFilter) is written to localStorage under the per-project key
* 'od:design-files:view-state:v1:<projectId>' and is restored correctly
* across three scenarios:
*
* (a) Tab-away / tab-back: navigating to a file tab and returning remounts
* the panel; prefs must survive.
* (b) Hard reload: localStorage persists across page.reload(); prefs must
* survive.
* (c) Project isolation: opening a second project starts with defaults, NOT
* the first project's persisted state.
*
* Each test must PASS on fix/web-design-files-persist-view and FAIL on
* origin/main (where no persistence is implemented).
*/
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
// Matches the constant in DesignFilesPanel.tsx
const VIEW_STATE_KEY_PREFIX = 'od:design-files:view-state:v1:';
// Config key expected by the web app to skip onboarding
const CONFIG_STORAGE_KEY = 'open-design:config';
// Minimal 1x1 PNG, base64-encoded
const TINY_PNG_B64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=';
// 90 s: each test seeds ~18 files via sequential POST requests; on a cold CI
// runner this alone takes 40-50 s before any assertion.
test.describe.configure({ timeout: 90_000 });
// Inject onboarding bypass and mock app-config before each test so the web app
// boots straight into the workspace without prompts.
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
}),
);
}, CONFIG_STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'mock',
skillId: null,
designSystemId: null,
agentModels: {},
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
});
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
agents: [
{
id: 'mock',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
],
},
});
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function waitForLoadingToClear(page: Page): Promise<void> {
await page
.getByText('Loading Open Design…')
.waitFor({ state: 'detached', timeout: 15_000 })
.catch(() => {});
}
async function gotoEntryHome(page: Page): Promise<void> {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
const privacyDialog = page
.getByRole('dialog')
.filter({ hasText: 'Help us improve Open Design' });
if (await privacyDialog.isVisible().catch(() => false)) {
await privacyDialog.getByRole('button', { name: /not now/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function createBlankProject(page: Page, name: string): Promise<string> {
await page.getByTestId('entry-nav-new-project').click();
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
await waitForLoadingToClear(page);
await expect(page).toHaveURL(/\/projects\//);
await expect(page.getByTestId('chat-composer')).toBeVisible();
const url = new URL(page.url());
const segments = url.pathname.split('/');
const projectId = segments[2];
if (!projectId) throw new Error(`could not extract projectId from ${url.pathname}`);
return projectId;
}
async function seedFile(
page: Page,
projectId: string,
name: string,
content: string,
encoding?: 'base64',
): Promise<void> {
const res = await page.request.post(`/api/projects/${projectId}/files`, {
data: { name, content, ...(encoding ? { encoding } : {}) },
timeout: 10_000,
});
expect(res.ok()).toBeTruthy();
}
function seedTextFile(page: Page, projectId: string, name: string): Promise<void> {
return seedFile(page, projectId, name, `# ${name}`);
}
function seedPngFile(page: Page, projectId: string, name: string): Promise<void> {
return seedFile(page, projectId, name, TINY_PNG_B64, 'base64');
}
async function openDesignFilesTab(page: Page): Promise<void> {
await page.getByTestId('design-files-tab').click();
// The Design Files panel renders a table once files are present; wait for
// the controls row that always appears at the top of the panel.
await expect(page.locator('.df-controls-row')).toBeVisible({ timeout: 10_000 });
}
// Wait until the per-page <select> is present — it only appears when
// sortedFiles.length > 15 (showListControls = true).
async function waitForPageSizeSelect(page: Page): Promise<void> {
await expect(page.getByTestId('df-page-size-select')).toBeVisible({ timeout: 10_000 });
}
// Read the view state from localStorage for a given projectId.
// Returns null when no entry has been written yet.
async function readStoredViewState(
page: Page,
projectId: string,
): Promise<Record<string, unknown> | null> {
const raw = await page.evaluate(
([prefix, pid]) => window.localStorage.getItem(prefix + pid) ?? 'null',
[VIEW_STATE_KEY_PREFIX, projectId] as [string, string],
);
return JSON.parse(raw) as Record<string, unknown> | null;
}
/**
* Seeds a project with 17 PNG files and 1 text file, then reloads.
* Enough files so showListControls (> 15) fires and the kind-filter button
* appears (2 kinds present).
*/
async function seedProjectWithFiles(page: Page, projectId: string): Promise<void> {
for (let i = 1; i <= 17; i++) {
await seedPngFile(page, projectId, `image-${String(i).padStart(2, '0')}.png`);
}
await seedTextFile(page, projectId, 'notes.txt');
await page.reload();
await waitForLoadingToClear(page);
}
/**
* Sets non-default view prefs: pageSize=15, sort=name/asc, kindFilter=image.
* Precondition: the Design Files tab must be open and the page-size select visible.
*/
async function setNonDefaultViewPrefs(page: Page): Promise<void> {
// Change pageSize from default 30 to 15
const pageSizeSelect = page.getByTestId('df-page-size-select');
await pageSizeSelect.selectOption('15');
await expect(pageSizeSelect).toHaveValue('15');
// Change sort from default mtime/desc to name/asc by clicking Name header
const nameHeader = page.locator('th.df-th-name button.df-th-btn');
await nameHeader.click();
await expect(page.locator('th.df-th-name')).toHaveAttribute('aria-sort', 'ascending');
// Apply kind filter: open the filter popover and check "Image"
const filterBtn = page.getByRole('button', { name: /filter by kind/i });
await filterBtn.click();
const filterPopover = page.getByRole('dialog', { name: /filter by kind/i });
await expect(filterPopover).toBeVisible();
await filterPopover.getByRole('checkbox', { name: /image/i }).check();
// Close the popover
await filterBtn.click();
await expect(filterPopover).toBeHidden();
}
/**
* Asserts that the non-default prefs set by setNonDefaultViewPrefs are visible.
*/
async function assertNonDefaultViewPrefs(page: Page): Promise<void> {
await expect(page.getByTestId('df-page-size-select')).toHaveValue('15');
await expect(page.locator('th.df-th-name')).toHaveAttribute('aria-sort', 'ascending');
await expect(page.getByRole('button', { name: /filter by kind/i })).toContainText(/image/i);
}
/**
* Asserts that the panel shows the default view prefs (as a fresh project would).
*/
async function assertDefaultViewPrefs(page: Page): Promise<void> {
await expect(page.getByTestId('df-page-size-select')).toHaveValue('30');
await expect(page.locator('th.df-th-name')).toHaveAttribute('aria-sort', 'none');
await expect(page.locator('th.df-th-time')).toHaveAttribute('aria-sort', 'descending');
await expect(page.getByRole('button', { name: /filter by kind/i })).not.toContainText(/image/i);
}
// ---------------------------------------------------------------------------
// Scenario (a): Tab-away / tab-back — prefs survive remount
// ---------------------------------------------------------------------------
test('(a) view prefs survive navigating away to a file tab and back', async ({ page }) => {
await gotoEntryHome(page);
const projectId = await createBlankProject(page, 'view-state-nav-test');
await seedProjectWithFiles(page, projectId);
await openDesignFilesTab(page);
await waitForPageSizeSelect(page);
await setNonDefaultViewPrefs(page);
// Verify the localStorage entry was written
const storedAfterChange = await readStoredViewState(page, projectId);
expect(storedAfterChange).not.toBeNull();
expect(storedAfterChange!.pageSize).toBe(15);
expect(storedAfterChange!.sortKey).toBe('name');
expect(storedAfterChange!.sortDir).toBe('asc');
expect(Array.isArray(storedAfterChange!.kindFilter)).toBe(true);
expect((storedAfterChange!.kindFilter as string[]).includes('image')).toBe(true);
// Navigate AWAY: open image-01.png in its own tab
const firstImageRow = page.getByTestId('design-file-row-image-01.png');
await firstImageRow.getByRole('button').first().click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
const navAwayTab = page.getByRole('tab', { name: /image-01\.png/i });
await expect(navAwayTab).toBeVisible();
await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'false');
// Navigate BACK — remounts DesignFilesPanel
await openDesignFilesTab(page);
await waitForPageSizeSelect(page);
// All four prefs must survive the remount
await assertNonDefaultViewPrefs(page);
});
// ---------------------------------------------------------------------------
// Scenario (b): Hard reload — prefs survive page.reload()
// ---------------------------------------------------------------------------
test('(b) view prefs survive a hard browser reload', async ({ page }) => {
await gotoEntryHome(page);
const projectId = await createBlankProject(page, 'view-state-reload-test');
await seedProjectWithFiles(page, projectId);
await openDesignFilesTab(page);
await waitForPageSizeSelect(page);
await setNonDefaultViewPrefs(page);
// Hard reload
await page.reload();
await waitForLoadingToClear(page);
await openDesignFilesTab(page);
await waitForPageSizeSelect(page);
// All four prefs must survive the reload
await assertNonDefaultViewPrefs(page);
// The localStorage key must still be intact
const storedAfterReload = await readStoredViewState(page, projectId);
expect(storedAfterReload).not.toBeNull();
expect(storedAfterReload!.pageSize).toBe(15);
expect(storedAfterReload!.sortKey).toBe('name');
expect(storedAfterReload!.sortDir).toBe('asc');
expect((storedAfterReload!.kindFilter as string[]).includes('image')).toBe(true);
});
// ---------------------------------------------------------------------------
// Scenario (c): Per-project key isolation — second project shows defaults
// ---------------------------------------------------------------------------
test('(c) second project view state is independent of the first project', async ({ page }) => {
// --- Project A: set non-default prefs ---
await gotoEntryHome(page);
const projectAId = await createBlankProject(page, 'view-state-project-a');
await seedProjectWithFiles(page, projectAId);
await openDesignFilesTab(page);
await waitForPageSizeSelect(page);
await setNonDefaultViewPrefs(page);
// Confirm project A's state was written to localStorage
const storedA = await readStoredViewState(page, projectAId);
expect(storedA).not.toBeNull();
expect(storedA!.pageSize).toBe(15);
// --- Project B: create, seed, assert defaults ---
await gotoEntryHome(page);
const projectBId = await createBlankProject(page, 'view-state-project-b');
// Seed enough files that showListControls fires in project B as well.
// Use text files so the kind-filter button appears (2 kinds: text + png).
for (let i = 1; i <= 17; i++) {
await seedTextFile(page, projectBId, `doc-${String(i).padStart(2, '0')}.txt`);
}
await seedPngFile(page, projectBId, 'icon.png');
await page.reload();
await waitForLoadingToClear(page);
await openDesignFilesTab(page);
await waitForPageSizeSelect(page);
// Project B must show defaults, not project A's persisted prefs
await assertDefaultViewPrefs(page);
// The per-project key for project B, if it exists at all, must NOT contain
// values inherited from project A. A default-value entry is acceptable.
const storedB = await readStoredViewState(page, projectBId);
if (storedB !== null) {
expect(storedB.pageSize).not.toBe(15);
expect(storedB.sortKey).not.toBe('name');
const kf = storedB.kindFilter;
if (Array.isArray(kf)) {
expect((kf as string[]).includes('image')).toBe(false);
}
}
});