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
This commit is contained in:
Patrick A 2026-05-29 23:39:27 -04:00 committed by GitHub
parent 65802542a2
commit 9146dc1c57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 693 additions and 5 deletions

View file

@ -52,6 +52,70 @@ type DesignFilesGroupMode = 'kind' | 'modified';
type ModifiedSection = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older';
type SortKey = 'name' | 'kind' | 'mtime';
type SortDir = 'asc' | 'desc';
// Storage key for per-project view state. Bump the version suffix (v1 → v2) when
// removing or renaming a persisted field — just adding an optional field is safe
// without a version bump. No cleanup of old keys on project deletion; the keys
// are small preference blobs and orphan gracefully.
const VIEW_STATE_KEY_PREFIX = 'od:design-files:view-state:v1:';
const DEFAULT_SORT_KEY: SortKey = 'mtime';
const DEFAULT_SORT_DIR: SortDir = 'desc';
const DEFAULT_PAGE_SIZE: number | 'all' = 30;
const PAGE_SIZE_OPTIONS = [15, 30, 45, 60, 'all'] as const;
interface PersistedViewState {
sortKey?: SortKey;
sortDir?: SortDir;
pageSize?: number | 'all';
kindFilter?: string[];
}
function readViewState(projectId: string): PersistedViewState {
try {
if (typeof window === 'undefined') return {};
const raw = localStorage.getItem(VIEW_STATE_KEY_PREFIX + projectId);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as PersistedViewState;
} catch {
return {};
}
}
function writeViewState(projectId: string, state: PersistedViewState): void {
try {
localStorage.setItem(VIEW_STATE_KEY_PREFIX + projectId, JSON.stringify(state));
} catch {
// localStorage unavailable (private mode, quota exceeded) — silently skip
}
}
function isSortKey(v: unknown): v is SortKey {
return v === 'name' || v === 'kind' || v === 'mtime';
}
function isSortDir(v: unknown): v is SortDir {
return v === 'asc' || v === 'desc';
}
function isPageSize(v: unknown): v is number | 'all' {
return (PAGE_SIZE_OPTIONS as ReadonlyArray<unknown>).includes(v);
}
// Validate that a value is one of the known ProjectFileKind literals. This
// guards against stored values that were valid under a previous schema but
// are no longer part of the union — they are silently dropped rather than
// poisoning the kindFilter state.
const VALID_KIND_SET: ReadonlySet<string> = new Set<ProjectFileKind>([
'html', 'image', 'video', 'audio', 'sketch', 'text',
'code', 'pdf', 'document', 'presentation', 'spreadsheet', 'binary',
]);
function isProjectFileKind(v: unknown): v is ProjectFileKind {
return typeof v === 'string' && VALID_KIND_SET.has(v);
}
type FileSystemEntryWithReader = FileSystemEntry & {
createReader?: () => FileSystemDirectoryReader;
};
@ -147,8 +211,27 @@ export function DesignFilesPanel({
const MENU_SAFE_PADDING = 8;
const [preview, setPreview] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<SortKey>('mtime');
const [sortDir, setSortDir] = useState<SortDir>('desc');
// Read once at mount; projectId is stable for this component instance
// (parent uses key={projectId} to remount on project switch).
const savedViewState = useRef(readViewState(projectId));
// Guard for the persist useEffect: skip the initial write so we only
// flush to localStorage when the user actually changes a preference.
// Without this, every project the user opens gets a default-value entry
// written on first render, making stale-key garbage grow unbounded.
// Note: React 18 StrictMode (active in next dev) fires effects twice,
// keeping refs intact across the simulated remount. This means the guard
// fires on the first effect run, sets the ref true, and the second run
// then writes the defaults. The result is a harmless default-value entry
// for the project; subsequent user changes overwrite it correctly. The
// invariant ("no write without a user action") only holds in production
// builds where StrictMode is not active.
const viewStateHasMounted = useRef(false);
const [sortKey, setSortKey] = useState<SortKey>(
() => isSortKey(savedViewState.current.sortKey) ? savedViewState.current.sortKey : DEFAULT_SORT_KEY,
);
const [sortDir, setSortDir] = useState<SortDir>(
() => isSortDir(savedViewState.current.sortDir) ? savedViewState.current.sortDir : DEFAULT_SORT_DIR,
);
const lastKeyPress = useRef<Map<string, number>>(new Map());
const [deleting, setDeleting] = useState(false);
const [installingFolder, setInstallingFolder] = useState<string | null>(null);
@ -160,7 +243,13 @@ export function DesignFilesPanel({
>(new Set());
const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null);
const [dayBoundary, setDayBoundary] = useState(() => Date.now());
const [kindFilter, setKindFilter] = useState<Set<ProjectFileKind>>(() => new Set());
const [kindFilter, setKindFilter] = useState<Set<ProjectFileKind>>(() => {
const { kindFilter: kf } = savedViewState.current;
if (!Array.isArray(kf) || kf.length === 0) return new Set();
// Validate each stored value against the current ProjectFileKind union so
// stale values from a prior schema (e.g. a renamed kind) are dropped silently.
return new Set(kf.filter(isProjectFileKind));
});
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const [currentDir, setCurrentDir] = useState<string>('');
@ -205,7 +294,12 @@ export function DesignFilesPanel({
// Drop any selected-filter kinds that no longer appear in the file list
// (e.g. after a delete leaves the kind empty). Keeps the filter UI honest
// and prevents a stale filter from silently hiding everything.
// Guard: skip when no kinds are available yet — availableKinds is empty only
// when files haven't loaded. Running cleanup against an empty set would
// clear a kindFilter that was correctly restored from localStorage before
// the async file list arrived.
useEffect(() => {
if (availableKinds.length === 0) return;
setKindFilter((prev) => {
if (prev.size === 0) return prev;
const present = new Set(availableKinds);
@ -235,7 +329,9 @@ export function DesignFilesPanel({
}, [filteredFiles, sortKey, sortDir]);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number | 'all'>(30);
const [pageSize, setPageSize] = useState<number | 'all'>(
() => isPageSize(savedViewState.current.pageSize) ? savedViewState.current.pageSize : DEFAULT_PAGE_SIZE,
);
const effectivePageSize = pageSize === 'all' ? Math.max(1, sortedFiles.length) : pageSize;
const totalPages = Math.max(1, Math.ceil(sortedFiles.length / effectivePageSize));
@ -276,6 +372,23 @@ export function DesignFilesPanel({
setPage(0);
}, [pageSize]);
// Persist view state so it survives navigation (the panel remounts via
// key={projectId} when the user tabs away and back).
// Skip the initial render: we only want to write when the user actually
// changes a preference, not on every project the user visits.
useEffect(() => {
if (!viewStateHasMounted.current) {
viewStateHasMounted.current = true;
return;
}
writeViewState(projectId, {
sortKey,
sortDir,
pageSize,
kindFilter: Array.from(kindFilter),
});
}, [projectId, sortKey, sortDir, pageSize, kindFilter]);
// Reset to the first page when the filter changes — the previous page
// index may no longer exist (or may now sit past the new totalPages).
useEffect(() => {
@ -1227,6 +1340,7 @@ export function DesignFilesPanel({
<label>
{t('designFiles.perPage')}:
<select
data-testid="df-page-size-select"
value={pageSize === 'all' ? 'all' : pageSize}
onChange={(e) => {
const val = e.target.value;

View file

@ -1,11 +1,21 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';
// Stub localStorage so the component's view-state persistence writes to an
// in-memory store. Cleared in beforeEach so no test bleeds state into the next.
const lsStore = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => lsStore.get(key) ?? null,
setItem: (key: string, value: string) => { lsStore.set(key, value); },
removeItem: (key: string) => { lsStore.delete(key); },
clear: () => { lsStore.clear(); },
});
function extForKind(kind: ProjectFileKind): string {
if (kind === 'html') return 'html';
if (kind === 'image') return 'png';
@ -80,6 +90,10 @@ function getSelects(container: HTMLElement) {
}
describe('DesignFilesPanel grouping', () => {
beforeEach(() => {
lsStore.clear();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
@ -297,6 +311,14 @@ describe('DesignFilesPanel grouping', () => {
});
describe('DesignFilesPanel large-list regression', () => {
beforeEach(() => {
lsStore.clear();
});
afterEach(() => {
cleanup();
});
it('renders only the default page size (30) rows with 500 files', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);

View file

@ -0,0 +1,191 @@
// @vitest-environment jsdom
//
// Red spec for bug #3a: view state (pageSize, sortKey, sortDir, kindFilter)
// resets on navigation because the component remounts via key={projectId}.
// These tests must go RED on origin/main and GREEN after the fix.
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';
// Minimal localStorage stub mirroring the pattern in state/config.test.ts
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => {
store.clear();
}),
});
function file(name: string, kind: ProjectFileKind = 'html', mtime = Date.now()): ProjectFile {
return { path: name, name, type: 'file', size: 1024, mtime, kind, mime: 'text/html' };
}
function generateFiles(count: number): ProjectFile[] {
const kinds: ProjectFileKind[] = ['html', 'image', 'sketch', 'text', 'code', 'pdf'];
return Array.from({ length: count }, (_, i) => {
const kind = kinds[i % kinds.length]!;
return file(`file-${i + 1}.html`, kind, Date.now() - i * 60_000);
});
}
function renderPanel(
files: ProjectFile[],
projectId = 'proj-a',
) {
return render(
<DesignFilesPanel
projectId={projectId}
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>,
);
}
function getPerPageSelect(container: HTMLElement): HTMLSelectElement {
// The per-page select is the first select in the panel
return container.querySelector<HTMLSelectElement>('.df-pagination-start select')!;
}
function getSortBtn(container: HTMLElement, label: string): HTMLElement {
return Array.from(container.querySelectorAll<HTMLElement>('.df-th-btn')).find(
(el) => el.textContent?.trim().startsWith(label),
)!;
}
describe('DesignFilesPanel view-state persistence', () => {
beforeEach(() => {
store.clear();
vi.mocked(localStorage.getItem).mockClear();
vi.mocked(localStorage.setItem).mockClear();
});
afterEach(() => {
cleanup();
});
it('restores pageSize from localStorage after remount', () => {
const files = generateFiles(500);
// First mount: change page size to 60
const first = renderPanel(files);
const sel = getPerPageSelect(first.container);
fireEvent.change(sel, { target: { value: '60' } });
first.unmount();
// Second mount simulates navigation away and back (key={projectId} causes remount)
const second = renderPanel(files);
const restoredSel = getPerPageSelect(second.container);
expect(restoredSel.value).toBe('60');
});
it('restores sort key from localStorage after remount', () => {
const files = generateFiles(50);
// First mount: click "Name" header to sort by name
const first = renderPanel(files);
fireEvent.click(getSortBtn(first.container, 'Name'));
first.unmount();
// Second mount: "Name" column should show the sort arrow
const second = renderPanel(files);
const nameBtn = getSortBtn(second.container, 'Name');
expect(nameBtn.textContent).toContain('↑');
});
it('restores sort direction from localStorage after remount', () => {
const files = generateFiles(50);
// First mount: click "Name" twice to get desc
const first = renderPanel(files);
fireEvent.click(getSortBtn(first.container, 'Name'));
fireEvent.click(getSortBtn(first.container, 'Name'));
first.unmount();
// Second mount: Name column should show desc arrow
const second = renderPanel(files);
const nameBtn = getSortBtn(second.container, 'Name');
expect(nameBtn.textContent).toContain('↓');
});
it('restores kindFilter from localStorage after remount', () => {
// Files with mixed kinds so the filter button appears
const files = [
file('a.html', 'html'),
file('b.png', 'image'),
file('c.txt', 'text'),
];
// First mount: open the filter popover and check 'HTML page'
const first = renderPanel(files);
const filterTrigger = first.container.querySelector<HTMLElement>('.df-kind-filter-trigger');
expect(filterTrigger).not.toBeNull();
fireEvent.click(filterTrigger!);
const checkboxes = first.container.querySelectorAll<HTMLInputElement>(
'.df-kind-filter-list input[type="checkbox"]',
);
// Check the first checkbox (HTML)
if (checkboxes[0]) fireEvent.click(checkboxes[0]);
first.unmount();
// Second mount: filter button should show active state (a kind is selected)
const second = renderPanel(files);
const trigger = second.container.querySelector('.df-kind-filter-trigger');
expect(trigger?.classList.contains('active')).toBe(true);
});
it('does not bleed pageSize from one project into another', () => {
const files = generateFiles(500);
// Project A: set page size 60
const first = renderPanel(files, 'proj-a');
fireEvent.change(getPerPageSelect(first.container), { target: { value: '60' } });
first.unmount();
// Project B: should still have the default (30), not project A's setting
const second = renderPanel(files, 'proj-b');
expect(getPerPageSelect(second.container).value).toBe('30');
});
it('writes view state to localStorage on pageSize change', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
fireEvent.change(getPerPageSelect(container), { target: { value: '45' } });
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith(
expect.stringMatching(/od:design-files:view-state/),
expect.any(String),
);
});
it('falls back to default pageSize when stored value is not a supported option', () => {
const files = generateFiles(500);
// Seed localStorage with an unsupported value (fractional, out-of-set integer)
for (const bad of [0.5, 17, 999, -1, 0]) {
vi.mocked(localStorage.getItem).mockReturnValueOnce(JSON.stringify({ pageSize: bad }));
const { container } = renderPanel(files);
expect(getPerPageSelect(container).value).toBe('30');
cleanup();
}
});
});

View file

@ -0,0 +1,361 @@
/**
* 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);
}
}
});