e3823ce470
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
import { test, expect } from './coverage-helpers';
|
|
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
|
|
|
|
/**
|
|
* Deeper files-page coverage (the largest source file): list/grid view toggle,
|
|
* column sorting, group-by lanes, breadcrumb navigation, and copy-via-dialog.
|
|
*/
|
|
test.beforeEach(async ({ page }) => {
|
|
await apiLogin(page);
|
|
});
|
|
|
|
function uniq(p: string): string {
|
|
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
}
|
|
|
|
test('sort columns and toggle list/grid views', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('SortHost'));
|
|
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
|
|
await apiUploadFile(page, SAMPLE_FILES.json(), folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.getByTestId('list-toolbar-view-list-btn').click();
|
|
// Column sort buttons live in the list-view header.
|
|
await page.getByTestId('files-sort-name-btn').click({ timeout: 5_000 }).catch(() => {});
|
|
await page.getByTestId('files-sort-size-btn').click({ timeout: 5_000 }).catch(() => {});
|
|
await page.getByTestId('files-sort-modified_at-btn').click({ timeout: 5_000 }).catch(() => {});
|
|
await page.getByTestId('list-toolbar-view-grid-btn').click();
|
|
});
|
|
|
|
test('sort by every column and group by every dimension', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('Dims'));
|
|
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
|
|
await apiUploadFile(page, SAMPLE_FILES.png(), folder.id);
|
|
await apiUploadFile(page, SAMPLE_FILES.pdf(), folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
|
|
|
|
// List view exposes the column-sort buttons.
|
|
await page.getByTestId('list-toolbar-view-list-btn').click();
|
|
for (const col of ['name', 'owner', 'type', 'size', 'modified_at']) {
|
|
await page.getByTestId(`files-sort-${col}-btn`).click({ timeout: 3_000 }).catch(() => {});
|
|
}
|
|
// Flip the sort direction.
|
|
await page.getByTestId('list-toolbar-sort-direction-btn').click({ timeout: 3_000 }).catch(() => {});
|
|
|
|
// Cycle through every group-by dimension.
|
|
for (const g of ['type', 'size', 'modifiedAt', 'createdAt']) {
|
|
await page.getByTestId('list-toolbar-groupby-btn').click({ timeout: 3_000 }).catch(() => {});
|
|
await page.getByTestId(`list-toolbar-groupby-${g}-item`).click({ timeout: 3_000 }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
test('group files by type', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('GroupHost'));
|
|
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
|
|
await apiUploadFile(page, SAMPLE_FILES.png(), folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.getByTestId('list-toolbar-groupby-btn').click();
|
|
await page.getByTestId('list-toolbar-groupby-type-item').click();
|
|
// The grouped (swimlane) view now renders; items remain visible.
|
|
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible();
|
|
});
|
|
|
|
test('file context menu: favorite, copy, open-parent', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('Ctx'));
|
|
await apiCreateFolder(page, uniq('CtxDest'));
|
|
const f = SAMPLE_FILES.text();
|
|
await apiUploadFile(page, f, folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Favorite the file from its context menu.
|
|
await page.getByTestId(f.name).click({ button: 'right' });
|
|
await page.getByTestId('files-ctx-favorite-item').click();
|
|
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
|
|
|
|
// Copy → opens the move dialog (copy mode); cancel.
|
|
await page.getByTestId(f.name).click({ button: 'right' });
|
|
await page.getByTestId('files-ctx-copy-item').click();
|
|
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('move-dialog-cancel-btn').click();
|
|
|
|
// Open-parent navigates to the file's parent folder.
|
|
await page.getByTestId(f.name).click({ button: 'right' });
|
|
await page.getByTestId('files-ctx-open-parent-item').click();
|
|
await expect(page).toHaveURL(/\/files\//, { timeout: 15_000 });
|
|
});
|
|
|
|
test('download a folder as a zip via the context menu', async ({ page }) => {
|
|
const folderName = uniq('Zip');
|
|
const folder = await apiCreateFolder(page, folderName);
|
|
await apiUploadFile(page, SAMPLE_FILES.text(), folder.id);
|
|
await page.goto('/files');
|
|
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.getByTestId(folderName).click({ button: 'right' });
|
|
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
|
|
await page.getByTestId('files-ctx-download-zip-item').click();
|
|
await downloadPromise;
|
|
});
|
|
|
|
test('deep-link ?file= opens the viewer', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('DeepLink'));
|
|
const f = SAMPLE_FILES.markdown();
|
|
await apiUploadFile(page, f, folder.id);
|
|
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
|
|
// Extract the file id from a row action button, then deep-link to it.
|
|
const tid = await page
|
|
.locator('[data-testid^="files-file-share-"]')
|
|
.first()
|
|
.getAttribute('data-testid');
|
|
const fileId = (tid ?? '').replace('files-file-share-', '');
|
|
await page.goto(`/files/${folder.id}?file=${fileId}`);
|
|
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId('file-viewer-close-btn').click();
|
|
});
|
|
|
|
test('download a file via the context menu', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('Dl'));
|
|
const f = SAMPLE_FILES.text();
|
|
await apiUploadFile(page, f, folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.getByTestId(f.name).click({ button: 'right' });
|
|
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
|
|
await page.getByTestId('files-ctx-download-item').click();
|
|
const download = await downloadPromise;
|
|
if (download) expect(download.suggestedFilename().length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('upload a folder via the hidden folder input', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('FolderUp'));
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId('files-upload-folder-input')).toBeAttached({ timeout: 15_000 });
|
|
|
|
// webkitdirectory input — set a couple of nested files to exercise the
|
|
// folder-upload handler.
|
|
await page
|
|
.getByTestId('files-upload-folder-input')
|
|
.setInputFiles([
|
|
{ name: 'up/a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') },
|
|
{ name: 'up/b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') },
|
|
])
|
|
.catch(() => {});
|
|
await page.waitForTimeout(1500);
|
|
// The page stays functional whether or not the upload fully completes.
|
|
await expect(page.getByTestId('files-upload-btn')).toBeVisible();
|
|
});
|
|
|
|
test('drag a file onto a subfolder to move it', async ({ page }) => {
|
|
const parent = await apiCreateFolder(page, uniq('DragHost'));
|
|
const destName = uniq('DragDest');
|
|
await apiCreateFolder(page, destName, parent.id);
|
|
const f = SAMPLE_FILES.text();
|
|
await apiUploadFile(page, f, parent.id);
|
|
|
|
await page.goto(`/files/${parent.id}`);
|
|
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.getByTestId(destName)).toBeVisible();
|
|
|
|
// HTML5 drag-and-drop: the row is draggable and the folder is a drop target.
|
|
await page.getByTestId(f.name).dragTo(page.getByTestId(destName));
|
|
|
|
// The file moved into the destination folder.
|
|
await expect(page.getByTestId(f.name)).toHaveCount(0, { timeout: 15_000 });
|
|
});
|
|
|
|
test('open a folder with the keyboard (focus + Enter)', async ({ page }) => {
|
|
const parent = await apiCreateFolder(page, uniq('KeyNav'));
|
|
const childName = uniq('KeyChild');
|
|
const child = await apiCreateFolder(page, childName, parent.id);
|
|
await page.goto(`/files/${parent.id}`);
|
|
await expect(page.getByTestId(childName)).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Focus the folder row and press Enter to open it (covers the keydown path).
|
|
await page.getByTestId(childName).focus();
|
|
await page.keyboard.press('Enter');
|
|
// The URL ends with the child id (nested under the parent path).
|
|
await expect(page).toHaveURL(new RegExp(`${child.id}$`), { timeout: 15_000 });
|
|
});
|
|
|
|
test('keyboard select-all and escape in the files list', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('Keys'));
|
|
await apiCreateFolder(page, uniq('k1'), folder.id);
|
|
await apiCreateFolder(page, uniq('k2'), folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.locator('.files-page')).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.locator('.files-page').click({ position: { x: 5, y: 5 } });
|
|
await page.keyboard.press('Control+a');
|
|
await expect(page.getByTestId('files-batch-bar')).toBeVisible({ timeout: 5_000 }).catch(() => {});
|
|
await page.keyboard.press('Escape');
|
|
});
|
|
|
|
test('breadcrumb navigates back to home', async ({ page }) => {
|
|
const folderName = uniq('Crumb');
|
|
const folder = await apiCreateFolder(page, folderName);
|
|
await page.goto(`/files/${folder.id}`);
|
|
// Breadcrumb home link returns to the root listing.
|
|
await page.getByTestId('files-breadcrumb-home-link').click();
|
|
await expect(page).toHaveURL(/\/files\/?$/);
|
|
});
|
|
|
|
test('open an image in the viewer and use the zoom controls', async ({ page }) => {
|
|
const folder = await apiCreateFolder(page, uniq('ImgView'));
|
|
const img = SAMPLE_FILES.png();
|
|
await apiUploadFile(page, img, folder.id);
|
|
await page.goto(`/files/${folder.id}`);
|
|
await expect(page.getByTestId(img.name)).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.getByTestId(img.name).click({ button: 'right' });
|
|
await page.getByTestId('files-ctx-file-open-item').click();
|
|
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Zoom controls render only for images.
|
|
await page.getByTestId('file-viewer-zoom-in-btn').click({ timeout: 3_000 }).catch(() => {});
|
|
await page.getByTestId('file-viewer-zoom-out-btn').click({ timeout: 3_000 }).catch(() => {});
|
|
await page.getByTestId('file-viewer-zoom-reset-btn').click({ timeout: 3_000 }).catch(() => {});
|
|
// The download + open-in-new-tab links are present in the viewer toolbar.
|
|
await expect(page.getByTestId('file-viewer-download-link')).toBeVisible();
|
|
await expect(page.getByTestId('file-viewer-open-new-tab-link')).toBeVisible();
|
|
await page.getByTestId('file-viewer-close-btn').click();
|
|
await expect(page.getByTestId('file-viewer-dialog')).toHaveCount(0);
|
|
});
|
|
|
|
test('copy a folder into another (copy mode keeps the source)', async ({ page }) => {
|
|
const srcName = uniq('CopySrc');
|
|
await apiCreateFolder(page, srcName);
|
|
const dest = await apiCreateFolder(page, uniq('CopyDest'));
|
|
|
|
await page.goto('/files');
|
|
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
|
|
await page.getByTestId(srcName).click({ button: 'right' });
|
|
await page.getByTestId('files-ctx-copy-item').click();
|
|
await expect(page.getByTestId('move-dialog')).toBeVisible();
|
|
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
|
|
await page.getByTestId('move-dialog-confirm-btn').click();
|
|
|
|
// Copy leaves the source in place.
|
|
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
|
|
// And a copy now lives in the destination.
|
|
await page.goto(`/files/${dest.id}`);
|
|
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
|
|
});
|