mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* 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
361 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|