From 063382ad605d96926b842d975e5e85b7ee046f3a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 13 Jul 2026 20:40:56 +0200 Subject: [PATCH] test(front): test dotfile view/hidden --- .../src/routes/files/[...path]/+page.svelte | 1 + tests/e2e/scenarios/helpers.ts | 20 +++ tests/e2e/spa/dotfile-filter.spec.ts | 164 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 tests/e2e/spa/dotfile-filter.spec.ts diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index a424c3ef..ad56c7c6 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -1559,6 +1559,7 @@ class:dropzone-active={dragOver} role="region" aria-label={t('nav.files', 'Files')} + data-testid="files-dropzone" ondragover={(e) => { e.preventDefault(); dragOver = true; diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts index 90707d11..c2924e86 100644 --- a/tests/e2e/scenarios/helpers.ts +++ b/tests/e2e/scenarios/helpers.ts @@ -224,6 +224,26 @@ export async function apiEmptyTrash(page: Page): Promise { } } +/** + * Flip the caller's `ui_preferences.hide_dotfiles` server-side. Used by the + * dotfile-filter e2e spec to establish a known state at test start and to + * clean up at teardown so sibling tests aren't polluted by a leftover + * "hidden" mode (the preference is persistent across sessions because it's + * stored on `auth.users.ui_preferences`, not in localStorage). + * + * PATCHes only `hide_dotfiles`; siblings in the bag (view_mode, future + * keys) survive the shallow-merge on the server side. + */ +export async function apiSetHideDotfiles(page: Page, hide: boolean): Promise { + const res = await page.request.patch('/api/auth/me/profile', { + headers: await csrfHeaders(page), + data: { ui_preferences: { hide_dotfiles: hide } }, + }); + if (!res.ok()) { + throw new Error(`apiSetHideDotfiles(${hide}) failed: ${res.status()} ${await res.text()}`); + } +} + /** A file to seed: its name, MIME type, and raw bytes. */ export type SeedFile = { name: string; mimeType: string; body: Buffer }; diff --git a/tests/e2e/spa/dotfile-filter.spec.ts b/tests/e2e/spa/dotfile-filter.spec.ts new file mode 100644 index 00000000..236368e9 --- /dev/null +++ b/tests/e2e/spa/dotfile-filter.spec.ts @@ -0,0 +1,164 @@ +import { test, expect } from './coverage-helpers'; +import { + apiCreateFolder, + apiLogin, + apiSetHideDotfiles, + apiTrashFolder, +} from '../scenarios/helpers'; + +/** + * Dotfile-hide filter — end-to-end coverage of the UI-only, per-user + * `hide_dotfiles` preference (JSONB `auth.users.ui_preferences`). + * + * Deliberately narrow scope: + * + * 1. Toggle: a `.hidden` folder in `/files` disappears when the + * toolbar eye button is pressed and reappears when it's pressed + * again. This is the "does the filter actually filter" test. + * + * 2. Empty state: a folder that contains ONLY dotfiles renders the + * "N hidden items — Show hidden files" affordance rather than the + * generic "This folder is empty" copy. Clicking the affordance + * flips the preference back off and the rows reappear. Guards + * against a mystery-empty-folder regression. + * + * 3. Trash safety: the hide preference is deliberately IGNORED on + * `/trash`, so a dotfile-named item still shows up for recovery. + * Pins the "safety-net surface always shows everything" rule + * against a future refactor that might extend the filter to + * trash by accident. + * + * Other surfaces (favorites, recent, photos, public share) all + * derive from the same `filterDotfiles` helper and the same + * `preferences.hideDotfiles` reactive read; unit tests cover the + * predicate, so we don't burn browser cycles verifying each list + * page renders one more filtered row correctly. The three tests + * above hit the three DIFFERENT semantics (filter, empty-state, + * exemption), which is what actually needs regression coverage. + * + * Isolation. `hide_dotfiles` is per-user and persists on the server, + * so it survives the login-per-test that other specs rely on for + * isolation. `beforeEach` explicitly resets it to `false` and + * `afterEach` restores it, otherwise a failed test would leave the + * whole suite running with the filter on. + */ + +test.beforeEach(async ({ page }) => { + await apiLogin(page); + await apiSetHideDotfiles(page, false); +}); + +test.afterEach(async ({ page }) => { + // Belt-and-braces: even if a test forgot to reset, restore the + // default so the next spec file starts from a known state. + await apiSetHideDotfiles(page, false).catch(() => {}); +}); + +function uniq(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +test('toolbar eye toggle hides and re-shows dotfiles in /files', async ({ page }) => { + // Two siblings at root: a regular folder + a `.`-prefixed one. + // Both should be visible with hide off (default), then only the + // regular one should remain after clicking the toolbar toggle. + const visible = uniq('Visible'); + const hidden = `.${uniq('hidden')}`; + await apiCreateFolder(page, visible); + await apiCreateFolder(page, hidden); + + await page.goto('/files'); + + // Baseline: both rows render. Row test-id = folder name (see + // ResourceList / +page.svelte's data-testid pattern used by the + // sibling files.spec.ts). + await expect(page.getByTestId(visible)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId(hidden)).toBeVisible(); + + // Flip the filter on via the eye toggle in the ListToolbar. The + // click routes through `preferences.toggleHideDotfiles()` which + // does an optimistic local mutation, so the row update should be + // visible before the debounced PATCH lands. + await page.getByTestId('list-toolbar-dotfile-toggle-btn').click(); + + // Visible row stays; hidden row vanishes. + await expect(page.getByTestId(visible)).toBeVisible(); + await expect(page.getByTestId(hidden)).toHaveCount(0); + + // Flip it back off — the hidden row must reappear. Same button; + // its state flips atomically with `preferences.hideDotfiles`. + await page.getByTestId('list-toolbar-dotfile-toggle-btn').click(); + await expect(page.getByTestId(hidden)).toBeVisible(); +}); + +test('empty-state hint appears when a folder holds only dotfiles', async ({ page }) => { + // Isolate the folder: nest inside a fresh parent so the only + // children are our dotfiles. Root has accumulated cruft from the + // suite and would drown the empty-state case. + const parent = await apiCreateFolder(page, uniq('OnlyDotfilesParent')); + const dot1 = `.${uniq('a')}`; + const dot2 = `.${uniq('b')}`; + await apiCreateFolder(page, dot1, parent.id); + await apiCreateFolder(page, dot2, parent.id); + + // Navigate into the parent. `/files/[...path]` treats the path + // segments as folder ids in the deep-link form. + await page.goto(`/files/${parent.id}`); + + // Baseline: both dotfiles are visible with hide off. + await expect(page.getByTestId(dot1)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId(dot2)).toBeVisible(); + + // Turn hide on. Folder becomes visually empty — but not the + // generic empty state; the "N hidden items" affordance appears + // instead, offering a one-click "Show hidden files" escape. + await page.getByTestId('list-toolbar-dotfile-toggle-btn').click(); + + const showHiddenBtn = page.getByTestId('files-show-hidden-btn'); + await expect(showHiddenBtn).toBeVisible({ timeout: 15_000 }); + // Regression pin: the generic "This folder is empty" hint MUST NOT + // show — that would hide the fact that content exists. + await expect(page.getByText('This folder is empty')).toHaveCount(0); + + // Click the "Show hidden files" button. It calls + // `preferences.setHideDotfiles(false)` and both dotfiles must + // reappear in the same view without a reload. + await showHiddenBtn.click(); + await expect(page.getByTestId(dot1)).toBeVisible(); + await expect(page.getByTestId(dot2)).toBeVisible(); +}); + +test('trash always shows dotfiles even when hide is on', async ({ page }) => { + // Create a `.`-prefixed folder, trash it, then flip the hide + // preference on. Trash MUST still show the row: hiding a + // trashed dotfile would let it ride the retention timer to + // permanent deletion without being reviewable — a + // safety-net-defeating footgun. + const dotname = `.${uniq('TrashedHidden')}`; + const folder = await apiCreateFolder(page, dotname); + await apiTrashFolder(page, folder.id); + + // Turn hide on server-side so the client picks it up on next + // session load (rather than driving it through the UI toggle + // and then navigating — same end state, one fewer moving part). + await apiSetHideDotfiles(page, true); + + await page.goto('/trash'); + + // Row must be present. Trash entries render the resource name + // as plain text (no per-row test-id keyed by name in the current + // template); text lookup is the reliable selector. + await expect(page.getByText(dotname)).toBeVisible({ timeout: 15_000 }); + + // Belt-and-braces: also verify the hide preference IS on in the + // background — otherwise the assertion above passes trivially + // because nothing was being hidden in the first place. We check + // by visiting /files (where the filter IS supposed to apply) and + // asserting the OTHER dotfile from the earlier test class would + // be hidden. Actually — because tests are ordered arbitrarily, + // we just verify the toolbar toggle reflects the current server + // state via aria-pressed on /files. + await page.goto('/files'); + const toggle = page.getByTestId('list-toolbar-dotfile-toggle-btn'); + await expect(toggle).toHaveAttribute('aria-pressed', 'true'); +});