test(e2e): Playwright + Vitest coverage harness and test instrumentation
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.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import * as path from 'path';
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
|
||||
|
||||
const PLUGIN_ZIP = path.join(__dirname, '..', 'fixtures', 'plugin-hello.zip');
|
||||
|
||||
/**
|
||||
* Admin panel — walk every tab and open a couple of forms. The admin route is
|
||||
* one of the largest source files; exercising each tab pulls in a lot of it
|
||||
* plus the admin endpoint module.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
test('walk every admin tab', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByTestId('admin-dashboard-tab')).toBeVisible({ timeout: 15_000 });
|
||||
// Dashboard is the default tab.
|
||||
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible();
|
||||
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await expect(page.getByTestId('admin-users-create-btn')).toBeVisible();
|
||||
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
|
||||
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible();
|
||||
|
||||
await page.getByTestId('admin-smtp-tab').click();
|
||||
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
|
||||
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
// Plugins panel content is conditional; assert the tab became active.
|
||||
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('open the create-user form', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await page.getByTestId('admin-users-create-btn').click();
|
||||
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('storage tab: change backend select', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {});
|
||||
});
|
||||
|
||||
test('storage tab: save the local backend settings', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
||||
// Keep the (safe) local backend and save — exercises the save handler without
|
||||
// reconfiguring storage to a remote backend.
|
||||
await page.getByTestId('admin-storage-backend-select').selectOption('local').catch(() => {});
|
||||
await page.getByTestId('admin-storage-save-btn').click().catch(() => {});
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('oidc tab: toggle enabled and fill issuer', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {});
|
||||
await page
|
||||
.getByTestId('admin-oidc-issuer-input')
|
||||
.fill('https://example.test/issuer', { timeout: 2_000 })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
function uniqUser(): string {
|
||||
return `u${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user via the admin form, dismiss the (modal-backdrop) create dialog,
|
||||
* and return a locator for that user's table row.
|
||||
*/
|
||||
async function createUserRow(
|
||||
page: import('@playwright/test').Page,
|
||||
uname: string,
|
||||
): Promise<ReturnType<import('@playwright/test').Page['locator']>> {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await page.getByTestId('admin-users-create-btn').click();
|
||||
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-create-user-username-input').fill(uname);
|
||||
await page.getByTestId('admin-create-user-email-input').fill(`${uname}@example.test`);
|
||||
await page.getByTestId('admin-create-user-password-input').fill('TestPassword1!');
|
||||
await page.getByTestId('admin-create-user-submit-btn').click();
|
||||
|
||||
const row = page.locator('tr').filter({ hasText: uname });
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The create flow can leave a modal open whose backdrop blocks row actions.
|
||||
await page.keyboard.press('Escape');
|
||||
const cancel = page.getByTestId('admin-create-user-cancel-btn');
|
||||
if (await cancel.isVisible().catch(() => false)) await cancel.click();
|
||||
await expect(page.locator('.modal__backdrop')).toHaveCount(0, { timeout: 10_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
test('create and delete a user', async ({ page }) => {
|
||||
const uname = uniqUser();
|
||||
const row = await createUserRow(page, uname);
|
||||
|
||||
// Delete — admin uses its own confirm modal (admin-confirm-ok-btn).
|
||||
await row.locator('[data-testid^="admin-user-delete-"]').first().click();
|
||||
await page.getByTestId('admin-confirm-ok-btn').click();
|
||||
await expect(page.locator('tr').filter({ hasText: uname })).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('toggle a user role and active state', async ({ page }) => {
|
||||
const uname = uniqUser();
|
||||
const row = await createUserRow(page, uname);
|
||||
|
||||
// Each of these confirms through the admin confirm modal.
|
||||
await row.locator('[data-testid^="admin-user-toggle-role-"]').first().click();
|
||||
await page.getByTestId('admin-confirm-ok-btn').click();
|
||||
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click(); // deactivate
|
||||
await page.getByTestId('admin-confirm-ok-btn').click();
|
||||
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click(); // reactivate
|
||||
await page.getByTestId('admin-confirm-ok-btn').click();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
});
|
||||
|
||||
test('reset a user password', async ({ page }) => {
|
||||
const uname = uniqUser();
|
||||
const row = await createUserRow(page, uname);
|
||||
|
||||
await row.locator('[data-testid^="admin-user-reset-password-"]').first().click();
|
||||
await expect(page.getByTestId('admin-reset-password-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-reset-password-input').fill('NewPassword1!');
|
||||
await page.getByTestId('admin-reset-password-submit-btn').click();
|
||||
await expect(page.getByTestId('admin-reset-password-form')).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('save a user quota and deactivate the user', async ({ page }) => {
|
||||
const uname = uniqUser();
|
||||
const row = await createUserRow(page, uname);
|
||||
|
||||
await row.locator('[data-testid^="admin-user-quota-"]').first().click();
|
||||
await expect(page.getByTestId('admin-quota-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-quota-save-btn').click();
|
||||
await expect(page.getByTestId('admin-quota-form')).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Deactivate (admin's own confirm modal).
|
||||
await row.locator('[data-testid^="admin-user-toggle-active-"]').first().click();
|
||||
await page.getByTestId('admin-confirm-ok-btn').click();
|
||||
});
|
||||
|
||||
test('save oidc settings', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-oidc-issuer-input').fill('https://example.test/issuer').catch(() => {});
|
||||
await page.getByTestId('admin-oidc-client-id-input').fill('client-123').catch(() => {});
|
||||
await page.getByTestId('admin-oidc-save-btn').click().catch(() => {});
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('install, toggle, and delete a plugin', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
// Install the example hello plugin (plugins are enabled in the coverage env).
|
||||
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
||||
|
||||
// The installed plugin shows up with toggle/delete controls.
|
||||
const toggle = page.locator('[data-testid^="admin-plugin-toggle-"]').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 20_000 });
|
||||
await toggle.click(); // disable
|
||||
await page.waitForTimeout(400);
|
||||
await toggle.click(); // re-enable
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Delete it (admin confirm modal).
|
||||
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
|
||||
const ok = page.getByTestId('admin-confirm-ok-btn');
|
||||
if (await ok.isVisible().catch(() => false)) await ok.click();
|
||||
await expect(page.locator('[data-testid^="admin-plugin-toggle-"]')).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('view plugin logs and details', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
||||
await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Open the logs/details view and exercise its controls, then close.
|
||||
await page.locator('[data-testid^="admin-plugin-details-"]').first().click();
|
||||
await page.getByTestId('admin-plugin-logs-level-select').selectOption({ index: 1 }).catch(() => {});
|
||||
await page.getByTestId('admin-plugin-logs-search-input').fill('hello').catch(() => {});
|
||||
await page.getByTestId('admin-plugin-logs-search-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
// Note: the live-tail checkbox opens an SSE stream that prevents the page from
|
||||
// settling, so it's left untested here.
|
||||
await page.getByTestId('admin-plugin-logs-close-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
|
||||
// Clean up: delete the plugin.
|
||||
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
|
||||
const ok = page.getByTestId('admin-confirm-ok-btn');
|
||||
if (await ok.isVisible().catch(() => false)) await ok.click();
|
||||
});
|
||||
|
||||
test('plugins tab: save retention settings', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
||||
// The retention form is conditional; fill + save it when present.
|
||||
const retention = page.getByTestId('admin-plugin-retention-form');
|
||||
if (await retention.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('admin-plugin-retention-days-input').fill('30').catch(() => {});
|
||||
await page.getByTestId('admin-plugin-retention-save-btn').click().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('toggle the dashboard registration setting', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId('admin-dashboard-registration-checkbox').click();
|
||||
});
|
||||
|
||||
test('send a test email from the smtp tab', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-smtp-tab').click();
|
||||
await expect(page.getByTestId('admin-smtp-to-input')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-smtp-to-input').fill('test@example.test');
|
||||
await page.getByTestId('admin-smtp-send-btn').click();
|
||||
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('storage tab: fill the S3 backend fields', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Switch to S3 to reveal + fill the conditional fields (no save — that would
|
||||
// reconfigure storage to an unreachable backend).
|
||||
await page.getByTestId('admin-storage-backend-select').selectOption('s3').catch(() => {});
|
||||
await page.getByTestId('admin-storage-endpoint-input').fill('https://s3.example.test', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('admin-storage-bucket-input').fill('e2e-bucket', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('admin-storage-region-input').fill('us-east-1', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('admin-storage-access-key-input').fill('AKIA', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('admin-storage-secret-key-input').fill('secret', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('admin-storage-path-style-checkbox').click({ timeout: 2_000 }).catch(() => {});
|
||||
// Switch back to the safe local backend.
|
||||
await page.getByTestId('admin-storage-backend-select').selectOption('local').catch(() => {});
|
||||
});
|
||||
|
||||
test('oidc tab: run discovery against a bogus issuer', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {});
|
||||
// Discovery fails (no real IdP) — exercises the discover + error path.
|
||||
await page.getByTestId('admin-oidc-discover-btn').click().catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('users tab: paginate the user list', async ({ page }) => {
|
||||
// The list paginates at PAGE_SIZE (25); create enough to get a second page.
|
||||
await apiLogin(page);
|
||||
for (let i = 0; i < 26; i++) {
|
||||
await apiAdminCreateUser(page, `pageu${Date.now()}${i}`);
|
||||
}
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-users-pager-next-btn').click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByTestId('admin-users-pager-prev-btn').click().catch(() => {});
|
||||
});
|
||||
|
||||
test('plugins tab: install then save retention settings', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
||||
await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Retention form is shown once a plugin exists.
|
||||
const retention = page.getByTestId('admin-plugin-retention-form');
|
||||
if (await retention.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('admin-plugin-retention-days-input').fill('30').catch(() => {});
|
||||
await page.getByTestId('admin-plugin-retention-max-input').fill('100').catch(() => {});
|
||||
await page.getByTestId('admin-plugin-retention-save-btn').click().catch(() => {});
|
||||
}
|
||||
|
||||
// Clean up.
|
||||
await page.locator('[data-testid^="admin-plugin-delete-"]').first().click();
|
||||
const ok = page.getByTestId('admin-confirm-ok-btn');
|
||||
if (await ok.isVisible().catch(() => false)) await ok.click();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* AppShell chrome — global search box (suggestions + submit), notification
|
||||
* bell, language menu, and user menu. Exercises AppShell.svelte.
|
||||
*/
|
||||
let seeded = false;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
if (!seeded) {
|
||||
await seedFilesAndFolders(page).catch(() => {});
|
||||
seeded = true;
|
||||
}
|
||||
});
|
||||
|
||||
test('global search box shows suggestions and submits', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The collapsed search toggle only appears on narrow layouts; ignore if absent.
|
||||
await page.getByTestId('appshell-search-toggle-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('appshell-search-input').fill('README', { timeout: 5_000 }).catch(() => {});
|
||||
await page.waitForTimeout(600); // debounced suggestion fetch
|
||||
await page.getByTestId('appshell-search-clear-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('appshell-search-input').fill('Documents', { timeout: 5_000 }).catch(() => {});
|
||||
await page.getByTestId('appshell-search-submit-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
});
|
||||
|
||||
test('notification, user, and language menus open', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId('appshell-user-menu-btn')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('appshell-notif-bell-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
|
||||
await page.getByTestId('appshell-user-menu-btn').click();
|
||||
await expect(page.getByTestId('appshell-user-menu-profile-item')).toBeVisible();
|
||||
await page.getByTestId('appshell-lang-toggle-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect, uiLogin, TEST_ADMIN } from './coverage-helpers';
|
||||
|
||||
test.describe('SPA · authentication', () => {
|
||||
test('login page renders the sign-in form', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByTestId('login-form')).toBeVisible();
|
||||
await expect(page.getByTestId('login-username-input')).toBeVisible();
|
||||
await expect(page.getByTestId('login-password-input')).toBeVisible();
|
||||
await expect(page.getByTestId('login-submit-btn')).toBeVisible();
|
||||
await expect(page).toHaveTitle(/OxiCloud/i);
|
||||
});
|
||||
|
||||
test('wrong password is rejected with an error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-username-input').fill(TEST_ADMIN.username);
|
||||
await page.getByTestId('login-password-input').fill('definitely-wrong-password');
|
||||
await page.getByTestId('login-submit-btn').click();
|
||||
|
||||
await expect(page.locator('.auth-error[role="alert"]')).toBeVisible();
|
||||
// Still on the login page — no redirect into the app.
|
||||
await expect(page.getByTestId('login-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('register and setup panels are reachable from login', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-to-register-btn').click();
|
||||
await expect(page.getByTestId('login-register-form')).toBeVisible();
|
||||
await page.getByTestId('login-register-to-login-btn').click();
|
||||
await expect(page.getByTestId('login-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('magic-link panel toggles open', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-magic-toggle-btn').click();
|
||||
await expect(page.getByTestId('login-magic-form')).toBeVisible();
|
||||
await expect(page.getByTestId('login-magic-email-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('successful login reaches the files app shell', async ({ page }) => {
|
||||
await uiLogin(page);
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible();
|
||||
await expect(page.getByTestId('appshell-user-menu-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('logout returns to the login page', async ({ page }) => {
|
||||
await uiLogin(page);
|
||||
await page.getByTestId('appshell-user-menu-btn').click();
|
||||
await page.getByTestId('appshell-user-menu-logout-btn').click();
|
||||
await page.waitForURL('**/login**', { timeout: 15_000 });
|
||||
await expect(page.getByTestId('login-form')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Batch selection bar — select-all then batch favorite / batch delete, each in
|
||||
* its own dedicated parent folder so it can't disturb other tests and starts
|
||||
* from a stable (un-re-rendered) state.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
/** Create a parent folder with two children and open it in list view. */
|
||||
async function openFolderWithChildren(
|
||||
page: import('@playwright/test').Page,
|
||||
): Promise<{ c1: string; c2: string }> {
|
||||
const parent = await apiCreateFolder(page, uniq('Batch'));
|
||||
const c1 = uniq('c1');
|
||||
const c2 = uniq('c2');
|
||||
await apiCreateFolder(page, c1, parent.id);
|
||||
await apiCreateFolder(page, c2, parent.id);
|
||||
await page.goto(`/files/${parent.id}`);
|
||||
await expect(page.getByTestId(c1)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('list-toolbar-view-list-btn').click();
|
||||
return { c1, c2 };
|
||||
}
|
||||
|
||||
test('select-all then batch favorite', async ({ page }) => {
|
||||
const { c1 } = await openFolderWithChildren(page);
|
||||
await page.getByTestId('files-select-all-checkbox').check();
|
||||
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
|
||||
await page.getByTestId('files-batch-favorite-btn').click();
|
||||
// Items remain in the folder after favoriting.
|
||||
await expect(page.getByTestId(c1)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('select-all then batch copy and download', async ({ page }) => {
|
||||
const { c1 } = await openFolderWithChildren(page);
|
||||
await page.getByTestId('files-select-all-checkbox').check();
|
||||
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
|
||||
|
||||
// Copy → the move dialog (copy mode); cancel.
|
||||
await page.getByTestId('files-batch-copy-btn').click();
|
||||
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('move-dialog-cancel-btn').click();
|
||||
|
||||
// Re-select and batch-download (a zip).
|
||||
await page.getByTestId('files-select-all-checkbox').check();
|
||||
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
|
||||
const dl = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
|
||||
await page.getByTestId('files-batch-download-btn').click();
|
||||
await dl;
|
||||
await expect(page.getByTestId(c1)).toBeVisible();
|
||||
});
|
||||
|
||||
test('select-all then batch delete', async ({ page }) => {
|
||||
const { c1, c2 } = await openFolderWithChildren(page);
|
||||
await page.getByTestId('files-select-all-checkbox').check();
|
||||
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
|
||||
await page.getByTestId('files-batch-delete-btn').click();
|
||||
await page.getByTestId('dialog-host-confirm-btn').click();
|
||||
|
||||
await expect(page.getByTestId(c1)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(c2)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Command palette — opens on Ctrl/Cmd+K, filters on typed input, and closes on
|
||||
* Escape. Covers CommandPalette.svelte.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
test('command palette opens, filters, and closes', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByTestId('command-palette-panel')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.getByTestId('command-palette-input').fill('trash');
|
||||
// At least one command item should match.
|
||||
await expect(page.locator('[data-testid^="command-palette-"][data-testid$="-item"]').first()).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('command-palette-panel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('command palette navigates via a command', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByTestId('command-palette-panel')).toBeVisible({ timeout: 5_000 });
|
||||
// Pick the first matching command via keyboard (covers the run path).
|
||||
await page.getByTestId('command-palette-input').fill('trash');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByTestId('command-palette-panel')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { test as base, TEST_ADMIN } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* `test` for the SvelteKit SPA coverage suite. Extends the shared `test`
|
||||
* (JS-error guard + container/stack support) with an auto fixture that, after
|
||||
* every test, reads the Istanbul `window.__coverage__` accumulated by the
|
||||
* instrumented build and appends it to `.nyc_output/` for an nyc report.
|
||||
*
|
||||
* Import `test`/`expect` from here in every `spa/*.spec.ts`.
|
||||
*/
|
||||
const NYC_DIR = path.join(__dirname, '..', '.nyc_output');
|
||||
|
||||
export const test = base.extend<{ collectCoverage: void }>({
|
||||
collectCoverage: [
|
||||
async ({ page }, use, testInfo) => {
|
||||
await use();
|
||||
// The instrumented bundle accumulates into window.__coverage__ across
|
||||
// SPA navigations; read it once at test end. May be undefined if the
|
||||
// page never loaded the app (pure-API test) — skip those.
|
||||
const coverage = await page
|
||||
.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__)
|
||||
.catch(() => undefined);
|
||||
if (coverage) {
|
||||
const file = path.join(
|
||||
NYC_DIR,
|
||||
`coverage-${testInfo.workerIndex}-${testInfo.testId}.json`,
|
||||
);
|
||||
fs.writeFileSync(file, JSON.stringify(coverage));
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect, TEST_ADMIN };
|
||||
|
||||
/**
|
||||
* Sign in through the SvelteKit login UI and wait until the app shell is
|
||||
* mounted. Uses stable `data-testid` selectors (kept in VITE_E2E builds).
|
||||
*/
|
||||
export async function uiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-username-input').fill(admin.username);
|
||||
await page.getByTestId('login-password-input').fill(admin.password);
|
||||
await page.getByTestId('login-submit-btn').click();
|
||||
await page.waitForURL('**/files**', { timeout: 15_000 });
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Device-pairing route — submit a (bogus) device code to exercise the lookup
|
||||
* path and its error/retry handling. Standalone page (no app shell).
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
test('submitting an unknown device code shows the retry path', async ({ page }) => {
|
||||
await page.goto('/device');
|
||||
await expect(page.getByTestId('device-code-form')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('device-code-input').fill('ZZZZ-ZZZZ');
|
||||
await page.getByTestId('device-continue-btn').click();
|
||||
|
||||
// An unknown code returns to a retry/error state (or stays on the form).
|
||||
await expect(
|
||||
page.getByTestId('device-retry-btn').or(page.getByTestId('device-code-form')),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// If a retry button appeared, use it to return to the code form.
|
||||
const retry = page.getByTestId('device-retry-btn');
|
||||
if (await retry.isVisible().catch(() => false)) {
|
||||
await retry.click();
|
||||
await expect(page.getByTestId('device-code-form')).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Error-path coverage — drive the catch/error branches that the happy-path
|
||||
* specs don't reach: a duplicate-folder 409 and an over-cap (413) upload.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('creating a duplicate folder surfaces an error toast', async ({ page }) => {
|
||||
const parent = await apiCreateFolder(page, uniq('DupHost'));
|
||||
const name = uniq('Dup');
|
||||
await apiCreateFolder(page, name, parent.id); // pre-create the clashing name
|
||||
|
||||
await page.goto(`/files/${parent.id}`);
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('files-new-folder-btn').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(name);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
|
||||
// The 409 is caught and raised as a toast.
|
||||
await expect(page.locator('[data-testid^="toaster-toast-"]').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('an over-cap upload is handled without crashing', async ({ page }) => {
|
||||
const folder = await apiCreateFolder(page, uniq('BigUp'));
|
||||
await page.goto(`/files/${folder.id}`);
|
||||
await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
// 5 MiB exceeds the 4 MiB test cap → the upload fails (413) and is caught.
|
||||
const big = Buffer.alloc(5 * 1024 * 1024, 65);
|
||||
await page.getByTestId('files-upload-file-input').setInputFiles({
|
||||
name: 'big.bin',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: big,
|
||||
});
|
||||
await page.waitForTimeout(2_000);
|
||||
// The page stays usable (the error path ran).
|
||||
await expect(page.getByTestId('files-upload-btn')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Favorites route — favorite a folder from the files context menu, see it in
|
||||
* /favorites, then unfavorite it via the row star. Covers the favorites page +
|
||||
* favorites endpoint.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('favorite, view, and unfavorite a folder', async ({ page }) => {
|
||||
const name = uniq('Fav');
|
||||
await apiCreateFolder(page, name);
|
||||
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(name).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-favorite-item').click();
|
||||
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
|
||||
|
||||
await page.goto('/favorites');
|
||||
const row = page.getByTestId(name);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The favorite star is hover-revealed; dispatch the click to toggle it off.
|
||||
await row.locator('[data-testid^="resource-list-favorite-"]').dispatchEvent('click');
|
||||
await expect(page.getByTestId(name)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('favorites batch select-all then move dialog', async ({ page }) => {
|
||||
const f1 = uniq('FavB1');
|
||||
const f2 = uniq('FavB2');
|
||||
await apiCreateFolder(page, f1);
|
||||
await apiCreateFolder(page, f2);
|
||||
|
||||
// Favorite both via the files context menu.
|
||||
await page.goto('/files');
|
||||
for (const n of [f1, f2]) {
|
||||
await expect(page.getByTestId(n)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(n).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-favorite-item').click();
|
||||
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
|
||||
}
|
||||
|
||||
await page.goto('/favorites');
|
||||
await expect(page.getByTestId(f1)).toBeVisible({ timeout: 15_000 });
|
||||
// The select-all checkbox lives in the list-view header.
|
||||
await page.getByTestId('list-toolbar-view-list-btn').click();
|
||||
await page.getByTestId('resource-list-select-all-checkbox').check();
|
||||
await expect(page.getByTestId('resource-list-batch-toolbar')).toBeVisible();
|
||||
|
||||
// Batch-move opens the move dialog; cancel it.
|
||||
await page.getByTestId('favorites-batch-move-btn').click();
|
||||
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('move-dialog-cancel-btn').click();
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* File-browser CRUD against the SvelteKit `/files` route — the single largest
|
||||
* source file. Drives the toolbar, the right-click context menu, and the
|
||||
* prompt/confirm dialogs, which also exercises the files/folders/favorites
|
||||
* endpoint modules.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('create a folder via the toolbar', async ({ page }) => {
|
||||
const name = uniq('Created');
|
||||
await page.goto('/files');
|
||||
await page.getByTestId('files-new-folder-btn').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(name);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('rename a folder via the context menu', async ({ page }) => {
|
||||
const before = uniq('Rename');
|
||||
const after = uniq('Renamed');
|
||||
await apiCreateFolder(page, before);
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId(before)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId(before).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-rename-item').click();
|
||||
const input = page.getByTestId('dialog-host-prompt-input');
|
||||
await input.fill(after);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
|
||||
await expect(page.getByTestId(after)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(before)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('favorite a folder via the context menu', async ({ page }) => {
|
||||
const name = uniq('Fav');
|
||||
await apiCreateFolder(page, name);
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId(name).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-favorite-item').click();
|
||||
|
||||
// Action completed — the row is still present and the menu has closed.
|
||||
await expect(page.getByTestId('files-context-menu')).toHaveCount(0);
|
||||
await expect(page.getByTestId(name)).toBeVisible();
|
||||
});
|
||||
|
||||
test('delete a folder via the context menu', async ({ page }) => {
|
||||
const name = uniq('Delete');
|
||||
await apiCreateFolder(page, name);
|
||||
await page.goto('/files');
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId(name).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-delete-item').click();
|
||||
await page.getByTestId('dialog-host-confirm-btn').click();
|
||||
|
||||
await expect(page.getByTestId(name)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('upload a file via the hidden file input', async ({ page }) => {
|
||||
const folder = uniq('Uploads');
|
||||
const created = await apiCreateFolder(page, folder);
|
||||
// Navigate straight into the (empty) folder by ID (the route keys on folder
|
||||
// id, not name) — avoids the crowded root listing and click ambiguity.
|
||||
await page.goto(`/files/${created.id}`);
|
||||
await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 });
|
||||
// Now inside the folder; upload a text file by setting the hidden input.
|
||||
const f = SAMPLE_FILES.text();
|
||||
await page.getByTestId('files-upload-file-input').setInputFiles({
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
buffer: f.body,
|
||||
});
|
||||
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('open a text file in the viewer', async ({ page }) => {
|
||||
const folderName = uniq('Viewer');
|
||||
const folder = await apiCreateFolder(page, folderName);
|
||||
const f = SAMPLE_FILES.markdown();
|
||||
await apiUploadFile(page, f, folder.id);
|
||||
|
||||
await page.goto('/files');
|
||||
await page.getByTestId(folderName).click();
|
||||
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Open via the context menu, then assert the inline viewer dialog appears.
|
||||
await page.getByTestId(f.name).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-file-open-item').click();
|
||||
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('file-viewer-close-btn').click();
|
||||
await expect(page.getByTestId('file-viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { seedAdmin } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Global setup for the SPA coverage suite:
|
||||
* 1. Wipe `.nyc_output/` so each run starts from a clean coverage slate.
|
||||
* 2. Seed the admin account against the SPA server at :8088.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
const nycDir = path.join(__dirname, '..', '.nyc_output');
|
||||
fs.rmSync(nycDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(nycDir, { recursive: true });
|
||||
|
||||
await seedAdmin('http://localhost:8088');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Groups route — create a group, expand it, search members, rename, and delete
|
||||
* (delete is a type-the-name confirm). Group rows are id-keyed, so we scope to
|
||||
* our own `li.group` by its unique name and prefix-match the buttons.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('group lifecycle: create, expand, rename, delete', async ({ page }) => {
|
||||
const name = uniq('Grp');
|
||||
const renamed = uniq('Grpr');
|
||||
await page.goto('/groups');
|
||||
await expect(page.getByTestId('groups-create-btn')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('groups-create-btn').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(name);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
|
||||
const row = page.locator('li.group').filter({ hasText: name });
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Expand → members panel, then run a member search (covers recipients).
|
||||
await row.locator('[data-testid^="groups-expand-"]').click();
|
||||
await expect(row.locator('[data-testid^="groups-members-panel-"]')).toBeVisible();
|
||||
await row.getByTestId('groups-member-add-input').fill('admin').catch(() => {});
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Rename.
|
||||
await row.locator('[data-testid^="groups-rename-"]').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(renamed);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
const renamedRow = page.locator('li.group').filter({ hasText: renamed });
|
||||
await expect(renamedRow).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Delete — the confirm prompt requires typing the group name.
|
||||
await renamedRow.locator('[data-testid^="groups-delete-"]').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(renamed);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
await expect(page.locator('li.group').filter({ hasText: renamed })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and remove a group member', async ({ page }) => {
|
||||
const username = await apiAdminCreateUser(page, uniq('grpmem'));
|
||||
const groupName = uniq('GrpM');
|
||||
|
||||
await page.goto('/groups');
|
||||
await page.getByTestId('groups-create-btn').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(groupName);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
|
||||
const row = page.locator('li.group').filter({ hasText: groupName });
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await row.locator('[data-testid^="groups-expand-"]').click();
|
||||
await expect(row.locator('[data-testid^="groups-members-panel-"]')).toBeVisible();
|
||||
|
||||
// Search the new user and add them.
|
||||
await row.getByTestId('groups-member-add-input').fill(username);
|
||||
const opt = row.locator('[data-testid^="groups-member-add-opt-user-"]').first();
|
||||
await expect(opt).toBeVisible({ timeout: 15_000 });
|
||||
await opt.click();
|
||||
|
||||
// Remove the member we just added.
|
||||
const remove = row.locator('[data-testid^="groups-member-remove-"]').first();
|
||||
await expect(remove).toBeVisible({ timeout: 15_000 });
|
||||
await remove.click();
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
|
||||
/**
|
||||
* Login route — register and magic-link flows (logged out). Exercises the
|
||||
* register/magic submit handlers in the login page + auth endpoints.
|
||||
*/
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('register a new account from the login page', async ({ page }) => {
|
||||
const u = uniq('reg');
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-to-register-btn').click();
|
||||
await expect(page.getByTestId('login-register-form')).toBeVisible();
|
||||
|
||||
await page.getByTestId('login-register-username-input').fill(u);
|
||||
await page.getByTestId('login-register-email-input').fill(`${u}@example.test`);
|
||||
await page.getByTestId('login-register-password-input').fill('TestPassword1!');
|
||||
await page.getByTestId('login-register-confirm-input').fill('TestPassword1!');
|
||||
await page.getByTestId('login-register-submit-btn').click();
|
||||
|
||||
// Either we land in the app or a notice/error appears — both run onRegister.
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('appshell-logo-link')
|
||||
.or(page.locator('.auth-error'))
|
||||
.or(page.getByTestId('login-register-form'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
// Note: the first-run setup panel is unreachable here — `login-to-setup-btn`
|
||||
// only renders when no admin exists, but the test env always seeds one.
|
||||
|
||||
test('register with mismatched passwords shows a validation error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-to-register-btn').click();
|
||||
await expect(page.getByTestId('login-register-form')).toBeVisible();
|
||||
|
||||
await page.getByTestId('login-register-username-input').fill(uniq('mm'));
|
||||
await page.getByTestId('login-register-email-input').fill('mm@example.test');
|
||||
await page.getByTestId('login-register-password-input').fill('TestPassword1!');
|
||||
await page.getByTestId('login-register-confirm-input').fill('Different1!');
|
||||
await page.getByTestId('login-register-submit-btn').click();
|
||||
|
||||
// Client-side validation rejects the mismatch before any request.
|
||||
await expect(page.locator('.auth-error[role="alert"]')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('an oidc callback code is exchanged on load', async ({ page }) => {
|
||||
// Landing with ?oidc_code triggers the SPA's OIDC code-exchange path; a bogus
|
||||
// code fails and falls back to the login form (exercises the handler).
|
||||
await page.goto('/login?oidc_code=fake-code-123');
|
||||
await expect(
|
||||
page.getByTestId('login-form').or(page.locator('.auth-error')).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('request a magic link from the login page', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByTestId('login-magic-toggle-btn').click();
|
||||
await expect(page.getByTestId('login-magic-form')).toBeVisible();
|
||||
|
||||
await page.getByTestId('login-magic-email-input').fill('someone@example.test');
|
||||
await page.getByTestId('login-magic-send-btn').click();
|
||||
// A status message resolves (success or error); give the request time to run.
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page.getByTestId('login-magic-form').or(page.getByTestId('login-form')).first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* MoveDialog coverage — move a folder into another via the context menu.
|
||||
* Exercises the move dialog's tree navigation and the folders move endpoint.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('move a folder into another via the move dialog', async ({ page }) => {
|
||||
const srcName = uniq('MoveSrc');
|
||||
const destName = uniq('MoveDest');
|
||||
await apiCreateFolder(page, srcName);
|
||||
const dest = await apiCreateFolder(page, destName);
|
||||
|
||||
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-move-item').click();
|
||||
await expect(page.getByTestId('move-dialog')).toBeVisible();
|
||||
|
||||
// Exercise the dialog tree navigation (into a folder, up to parent, home),
|
||||
// then settle into the destination and confirm.
|
||||
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
|
||||
await page.getByTestId('move-dialog-parent-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('move-dialog-home-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
|
||||
await page.getByTestId('move-dialog-confirm-btn').click();
|
||||
|
||||
// Source left the root listing.
|
||||
await expect(page.getByTestId(srcName)).toHaveCount(0, { timeout: 15_000 });
|
||||
// And now lives inside the destination (route keys on folder id).
|
||||
await page.goto(`/files/${dest.id}`);
|
||||
await expect(page.getByTestId(srcName)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('navigate a nested tree in the move dialog', async ({ page }) => {
|
||||
const srcName = uniq('NavSrc');
|
||||
await apiCreateFolder(page, srcName);
|
||||
const dest = await apiCreateFolder(page, uniq('NavDest'));
|
||||
const sub = await apiCreateFolder(page, uniq('NavSub'), dest.id);
|
||||
|
||||
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-move-item').click();
|
||||
await expect(page.getByTestId('move-dialog')).toBeVisible();
|
||||
|
||||
// Descend dest → sub, climb back via parent, jump home, re-enter dest, move.
|
||||
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
|
||||
await page.getByTestId(`move-dialog-folder-${sub.id}`).click();
|
||||
await page.getByTestId('move-dialog-parent-btn').click();
|
||||
await page.getByTestId('move-dialog-home-btn').click();
|
||||
await page.getByTestId(`move-dialog-folder-${dest.id}`).click();
|
||||
await page.getByTestId('move-dialog-confirm-btn').click();
|
||||
|
||||
await expect(page.getByTestId(srcName)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Music route — playlist lifecycle, the add-tracks dialog, and the audio player
|
||||
* (driven with a real WAV fixture so the track shows up in the picker). The
|
||||
* music page is one of the largest source files.
|
||||
*/
|
||||
const TONE_WAV = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tone.wav'));
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
/** Create a playlist and return its name; works whether or not others exist. */
|
||||
async function createPlaylist(page: import('@playwright/test').Page, name: string): Promise<void> {
|
||||
await page.goto('/music');
|
||||
const createBtn = page.getByTestId('music-create-playlist-btn');
|
||||
const emptyBtn = page.getByTestId('music-create-playlist-empty-btn');
|
||||
await expect(createBtn.or(emptyBtn)).toBeVisible({ timeout: 15_000 });
|
||||
if (await createBtn.isVisible().catch(() => false)) await createBtn.click();
|
||||
else await emptyBtn.click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(name);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
test('playlist lifecycle: create, toggle public, rename, delete', async ({ page }) => {
|
||||
const name = uniq('PL');
|
||||
const renamed = uniq('PLr');
|
||||
await createPlaylist(page, name);
|
||||
|
||||
// Select the playlist → detail panel with its controls.
|
||||
await page.getByTestId(name).click();
|
||||
await expect(page.getByTestId('music-rename-playlist-btn')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('music-toggle-public-btn').click().catch(() => {});
|
||||
|
||||
await page.getByTestId('music-rename-playlist-btn').click();
|
||||
await page.getByTestId('dialog-host-prompt-input').fill(renamed);
|
||||
await page.getByTestId('dialog-host-submit-btn').click();
|
||||
await expect(page.getByTestId(renamed)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('music-delete-playlist-btn').click();
|
||||
await page.getByTestId('dialog-host-confirm-btn').click();
|
||||
await expect(page.getByTestId(renamed)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('reorder playlist tracks by dragging', async ({ page }) => {
|
||||
const folder = await apiCreateFolder(page, uniq('Audio2'));
|
||||
const token = `multi${Date.now()}`;
|
||||
await apiUploadFile(page, { name: `${token}-1.wav`, mimeType: 'audio/wav', body: TONE_WAV }, folder.id);
|
||||
await apiUploadFile(page, { name: `${token}-2.wav`, mimeType: 'audio/wav', body: TONE_WAV }, folder.id);
|
||||
|
||||
const name = uniq('PLr2');
|
||||
await createPlaylist(page, name);
|
||||
await page.getByTestId(name).click();
|
||||
await page.getByTestId('music-add-tracks-btn').click();
|
||||
await expect(page.getByTestId('music-add-tracks-dialog')).toBeVisible();
|
||||
await page.getByTestId('music-add-tracks-search-input').fill(token);
|
||||
await page.getByTestId(`${token}-1.wav`).check({ timeout: 15_000 });
|
||||
await page.getByTestId(`${token}-2.wav`).check({ timeout: 5_000 });
|
||||
await page.getByTestId('music-add-tracks-confirm-btn').click();
|
||||
await expect(page.getByTestId('music-add-tracks-dialog')).toHaveCount(0);
|
||||
|
||||
// Drag the first track onto the second to reorder.
|
||||
const tracks = page.locator('.music-track');
|
||||
await expect(tracks).toHaveCount(2, { timeout: 15_000 });
|
||||
await tracks.nth(0).dragTo(tracks.nth(1));
|
||||
});
|
||||
|
||||
test('add-tracks dialog opens, searches, and closes', async ({ page }) => {
|
||||
const name = uniq('PLt');
|
||||
await createPlaylist(page, name);
|
||||
await page.getByTestId(name).click();
|
||||
await expect(page.getByTestId('music-add-tracks-btn')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('music-add-tracks-btn').click();
|
||||
await expect(page.getByTestId('music-add-tracks-dialog')).toBeVisible();
|
||||
await page.getByTestId('music-add-tracks-search-input').fill('song');
|
||||
await page.getByTestId('music-add-tracks-close-btn').click();
|
||||
await expect(page.getByTestId('music-add-tracks-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('add an audio track and drive the player', async ({ page }) => {
|
||||
// A real WAV so the backend recognises it as audio and lists it in the picker.
|
||||
const folder = await apiCreateFolder(page, uniq('Audio'));
|
||||
const songName = `${uniq('song')}.wav`;
|
||||
await apiUploadFile(page, { name: songName, mimeType: 'audio/wav', body: TONE_WAV }, folder.id);
|
||||
|
||||
const name = uniq('PLp');
|
||||
await createPlaylist(page, name);
|
||||
await page.getByTestId(name).click();
|
||||
await expect(page.getByTestId('music-add-tracks-btn')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Open the picker and search by name (an empty query may match nothing).
|
||||
await page.getByTestId('music-add-tracks-btn').click();
|
||||
await expect(page.getByTestId('music-add-tracks-dialog')).toBeVisible();
|
||||
await page.getByTestId('music-add-tracks-search-input').fill(songName.replace('.wav', ''));
|
||||
const songCheckbox = page.getByTestId(songName);
|
||||
await expect(songCheckbox).toBeVisible({ timeout: 15_000 });
|
||||
await songCheckbox.check();
|
||||
await page.getByTestId('music-add-tracks-confirm-btn').click();
|
||||
await expect(page.getByTestId('music-add-tracks-dialog')).toHaveCount(0);
|
||||
|
||||
// Playlist-level actions on the detail panel.
|
||||
await page.getByTestId('music-play-all-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('music-shuffle-play-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
|
||||
// Play the track and exercise the transport controls.
|
||||
await page.locator('[data-testid^="music-track-play-"]').first().click({ timeout: 5_000 });
|
||||
await page.getByTestId('music-player-play-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('music-player-shuffle-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('music-player-repeat-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('music-player-next-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('music-player-mute-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
|
||||
// Queue panel — open, remove a queued track, then close.
|
||||
await page.getByTestId('music-player-queue-toggle-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page
|
||||
.locator('[data-testid^="music-queue-remove-"]')
|
||||
.first()
|
||||
.click({ timeout: 2_000 })
|
||||
.catch(() => {});
|
||||
await page.getByTestId('music-queue-close-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
|
||||
// Edit the playlist description.
|
||||
await page.getByTestId('music-edit-description-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
const descInput = page.getByTestId('dialog-host-prompt-input');
|
||||
if (await descInput.isVisible().catch(() => false)) {
|
||||
await descInput.fill('e2e description');
|
||||
await page.getByTestId('dialog-host-submit-btn').click().catch(() => {});
|
||||
}
|
||||
|
||||
// Manage-shares dialog.
|
||||
await page.getByTestId('music-manage-shares-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('music-shares-close-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
|
||||
// Set a cover image (exercises the cover upload path).
|
||||
await page.getByTestId('music-set-cover-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page
|
||||
.getByTestId('music-cover-input')
|
||||
.setInputFiles({
|
||||
name: 'cover.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Loads every primary route while authenticated. Each route is its own test
|
||||
* (own page/document) so its `window.__coverage__` is captured before the next
|
||||
* navigation resets it — a `goto` loop in one test would only keep the last
|
||||
* route's data. Visiting a route mounts its `+page.svelte` and the API
|
||||
* endpoint modules it imports, which is the bulk of first-load coverage.
|
||||
*/
|
||||
let seeded = false;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
if (!seeded) {
|
||||
// Seed once per worker. Tolerant of a re-seed (e.g. after a worker reset)
|
||||
// hitting "already exists" so it never fails an unrelated route test.
|
||||
await seedFilesAndFolders(page).catch(() => {});
|
||||
seeded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Routes that render inside the standard app shell (assert the shell logo).
|
||||
const SHELL_ROUTES: { path: string; name: string }[] = [
|
||||
{ path: '/files', name: 'files' },
|
||||
{ path: '/shared', name: 'shared' },
|
||||
{ path: '/shared-with-me', name: 'shared-with-me' },
|
||||
{ path: '/recent', name: 'recent' },
|
||||
{ path: '/favorites', name: 'favorites' },
|
||||
{ path: '/photos', name: 'photos' },
|
||||
{ path: '/music', name: 'music' },
|
||||
{ path: '/trash', name: 'trash' },
|
||||
{ path: '/search?q=README', name: 'search' },
|
||||
{ path: '/profile', name: 'profile' },
|
||||
{ path: '/groups', name: 'groups' },
|
||||
{ path: '/admin', name: 'admin' },
|
||||
];
|
||||
|
||||
for (const route of SHELL_ROUTES) {
|
||||
test(`route ${route.name} mounts inside the app shell`, async ({ page }) => {
|
||||
await page.goto(route.path);
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
// Let lazy chunks + onMount data fetches settle so their code is exercised.
|
||||
await page.waitForLoadState('networkidle').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// The device-pairing route is a standalone page (OAuth device-code flow); it
|
||||
// does NOT use the app shell, so assert its own form instead.
|
||||
test('route device renders the pairing form', async ({ page }) => {
|
||||
await page.goto('/device');
|
||||
await expect(page.getByTestId('device-code-form')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('device-code-input')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Photos route — populate the grid with uploaded images, open the lightbox and
|
||||
* navigate it, and switch the moments/places/people subnav. Covers the photos
|
||||
* page + PhotoLightbox (and mounts PlacesMap / PeopleView).
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
async function seedPhotos(page: import('@playwright/test').Page, n: number): Promise<void> {
|
||||
const folder = await apiCreateFolder(page, uniq('Photos'));
|
||||
for (let i = 0; i < n; i++) {
|
||||
await apiUploadFile(
|
||||
page,
|
||||
{ name: `${uniq('pic')}.png`, mimeType: 'image/png', body: SAMPLE_FILES.png().body },
|
||||
folder.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('photo grid opens the lightbox and navigates', async ({ page }) => {
|
||||
await seedPhotos(page, 3);
|
||||
await page.goto('/photos');
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const tile = page.locator('[data-testid^="photo-tile-"]').first();
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
await tile.click();
|
||||
|
||||
await expect(page.getByTestId('photo-lightbox')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('photo-lightbox-next-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('photo-lightbox-prev-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('photo-lightbox-close-btn').click();
|
||||
await expect(page.getByTestId('photo-lightbox')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('select photos, toggle layout, and batch-delete', async ({ page }) => {
|
||||
await seedPhotos(page, 3);
|
||||
await page.goto('/photos');
|
||||
|
||||
// The tile check button is hover-revealed; dispatch the click to select.
|
||||
const tileCheck = page.locator('[data-testid^="photo-tile-check-"]').first();
|
||||
await expect(tileCheck).toBeAttached({ timeout: 15_000 });
|
||||
await tileCheck.dispatchEvent('click');
|
||||
await expect(page.getByTestId('photos-batch-bar')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Layout toggles.
|
||||
await page.getByTestId('photos-layout-justified-btn').click();
|
||||
await page.getByTestId('photos-layout-square-btn').click();
|
||||
|
||||
// Batch-delete the selection (confirm if prompted).
|
||||
await page.getByTestId('photos-batch-delete-btn').click();
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
});
|
||||
|
||||
test('photos subnav switches to places and people', async ({ page }) => {
|
||||
await seedPhotos(page, 2);
|
||||
await page.goto('/photos');
|
||||
await expect(page.getByTestId('photos-tab-places')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('photos-tab-places').click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const peopleTab = page.getByTestId('photos-tab-people');
|
||||
if (await peopleTab.isVisible().catch(() => false)) {
|
||||
await peopleTab.click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
await page.getByTestId('photos-tab-moments').click();
|
||||
await expect(page.getByTestId('photos-tab-moments')).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Profile route — edit the profile, open the avatar panel, and generate an app
|
||||
* password. Covers the profile page + the auth/admin profile endpoints it uses.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
test('edit the profile form', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByTestId('profile-edit-form')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('profile-given-name-input').fill('E2E');
|
||||
await page.getByTestId('profile-family-name-input').fill('Tester');
|
||||
await page.getByTestId('profile-language-select').selectOption({ index: 1 }).catch(() => {});
|
||||
await page.getByTestId('profile-notify-on-share-checkbox').click({ timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('profile-save-btn').click();
|
||||
// The save button is the same control; just ensure the form stays mounted.
|
||||
await expect(page.getByTestId('profile-edit-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('open the avatar edit panel and upload an image', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByTestId('profile-avatar-edit-btn')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('profile-avatar-edit-btn').click();
|
||||
await expect(page.getByTestId('profile-avatar-edit-panel')).toBeVisible();
|
||||
|
||||
await page.getByTestId('profile-avatar-url-tab').click();
|
||||
await expect(page.getByTestId('profile-avatar-url-input')).toBeVisible();
|
||||
|
||||
// Upload tab → set an image (exercises client-side image resizing).
|
||||
await page.getByTestId('profile-avatar-upload-tab').click();
|
||||
await page
|
||||
.getByTestId('profile-avatar-file-input')
|
||||
.setInputFiles({
|
||||
name: 'avatar.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
})
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByTestId('profile-avatar-save-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
// Remove the avatar we just set (the remove button shows once one exists).
|
||||
await page.getByTestId('profile-avatar-edit-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('profile-avatar-remove-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('profile-avatar-cancel-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
});
|
||||
|
||||
test('password change rejects a wrong current password', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByTestId('profile-password-form')).toBeVisible({ timeout: 15_000 });
|
||||
// Wrong current password → rejected, so the admin login stays valid for
|
||||
// other tests while still exercising the change-password handler.
|
||||
await page.getByTestId('profile-current-password-input').fill('definitely-wrong-current');
|
||||
await page.getByTestId('profile-new-password-input').fill('NewPassword1!');
|
||||
await page.getByTestId('profile-confirm-password-input').fill('NewPassword1!');
|
||||
await page.getByTestId('profile-update-password-btn').click();
|
||||
await expect(page.getByTestId('profile-password-form')).toBeVisible();
|
||||
});
|
||||
|
||||
test('generate and revoke an app password', async ({ page }) => {
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByTestId('profile-app-pw-label-input')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('profile-app-pw-label-input').fill('e2e-token');
|
||||
await page.getByTestId('profile-app-pw-generate-btn').click();
|
||||
|
||||
// The generated secret + copy button appear once created.
|
||||
await expect(page.getByTestId('profile-app-pw-copy-btn')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('profile-app-pw-copy-btn').click().catch(() => {});
|
||||
|
||||
// Revoke the app password we just created.
|
||||
const revoke = page.locator('[data-testid^="profile-app-pw-revoke-"]').first();
|
||||
if (await revoke.isVisible().catch(() => false)) {
|
||||
await revoke.click();
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
}
|
||||
await expect(page.getByTestId('profile-app-pw-generate-btn')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiRecordRecent } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Recent route — populate it via the recents API (the SPA doesn't auto-record
|
||||
* through this endpoint), then exercise the list, batch selection, and clear.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('recent shows accessed items, batch selection, and clear', async ({ page }) => {
|
||||
const a = await apiCreateFolder(page, uniq('RecA'));
|
||||
const b = await apiCreateFolder(page, uniq('RecB'));
|
||||
await apiRecordRecent(page, 'folder', a.id);
|
||||
await apiRecordRecent(page, 'folder', b.id);
|
||||
|
||||
await page.goto('/recent');
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Switch to list view (reveals the select-all header) and batch-select.
|
||||
await page.getByTestId('list-toolbar-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
const selectAll = page.getByTestId('resource-list-select-all-checkbox');
|
||||
if (await selectAll.isVisible().catch(() => false)) {
|
||||
await selectAll.check();
|
||||
await page.getByTestId('recent-batch-move-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
await page.getByTestId('move-dialog-cancel-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// Clear the history if the control is present.
|
||||
const clearBtn = page.getByTestId('recent-clear-btn');
|
||||
if (await clearBtn.isVisible().catch(() => false)) {
|
||||
await clearBtn.click();
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
}
|
||||
});
|
||||
|
||||
test('recent grouping and sort cycle (ResourceList toolbar)', async ({ page }) => {
|
||||
const a = await apiCreateFolder(page, uniq('RecG'));
|
||||
await apiRecordRecent(page, 'folder', a.id);
|
||||
await page.goto('/recent');
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('list-toolbar-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
|
||||
|
||||
// Cycle every group-by dimension exposed by the shared ResourceList toolbar.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.getByTestId('list-toolbar-groupby-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
await page
|
||||
.locator('[data-testid^="list-toolbar-groupby-"][data-testid$="-item"]')
|
||||
.nth(i)
|
||||
.click({ timeout: 2_000 })
|
||||
.catch(() => {});
|
||||
}
|
||||
await page.getByTestId('list-toolbar-sort-direction-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Search route — seed a uniquely-named file, query for it, then exercise the
|
||||
* type/size/date/sort filters and the clear-filters control. Covers the search
|
||||
* route + search endpoint module.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
test('search finds a seeded file and applies filters', async ({ page }) => {
|
||||
const token = `srch${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
const folder = await apiCreateFolder(page, `SearchHost-${token}`);
|
||||
await apiUploadFile(
|
||||
page,
|
||||
{ name: `${token}.txt`, mimeType: 'text/plain', body: Buffer.from('searchable content') },
|
||||
folder.id,
|
||||
);
|
||||
|
||||
await page.goto(`/search?q=${token}`);
|
||||
await expect(page.getByTestId(`${token}.txt`)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Exercise the filter + sort controls (onchange handlers). Done after the
|
||||
// result assertion; selecting by index keeps this resilient to option sets.
|
||||
// Short timeouts so optional controls that aren't rendered fail fast instead
|
||||
// of waiting the full test timeout (the scope/clear buttons are conditional).
|
||||
const opt = { timeout: 2_000 };
|
||||
await page.getByTestId('search-sort-select').selectOption({ index: 1 }, opt).catch(() => {});
|
||||
await page.getByTestId('search-size-filter-select').selectOption({ index: 1 }, opt).catch(() => {});
|
||||
await page.getByTestId('search-date-filter-select').selectOption({ index: 1 }, opt).catch(() => {});
|
||||
await page.getByTestId('search-clear-filters-btn').click(opt).catch(() => {});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiAdminCreateUser, apiCreateGroup } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* User-to-user sharing — create a second user, add them as a member through the
|
||||
* ShareDialog people tab, change role / toggle notify, then manage the grant
|
||||
* from /shared. Covers the ShareDialog member paths + shared user-grant rows.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('share a folder with another user and manage the grant', async ({ page }) => {
|
||||
const username = await apiAdminCreateUser(page, uniq('shareu'));
|
||||
const folderName = uniq('UserShare');
|
||||
// Isolate in a fresh parent (the /files root accumulates 100s of folders
|
||||
// across the suite and won't render an arbitrary one).
|
||||
const parent = await apiCreateFolder(page, uniq('UserShareHost'));
|
||||
await apiCreateFolder(page, folderName, parent.id);
|
||||
|
||||
// Open the share dialog → people tab → search for the user and add them.
|
||||
await page.goto(`/files/${parent.id}`);
|
||||
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(folderName).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-share-item').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
await page.getByTestId('share-dialog-people-tab').click();
|
||||
|
||||
await page.getByTestId('share-dialog-search-input').fill(username);
|
||||
const result = page.locator('[data-testid^="share-dialog-result-user-"]').first();
|
||||
await expect(result).toBeVisible({ timeout: 15_000 });
|
||||
await result.click();
|
||||
|
||||
// The member row appears with role/notify/remove controls.
|
||||
const memberRole = page.locator('[data-testid^="share-dialog-member-role-"]').first();
|
||||
await expect(memberRole).toBeVisible({ timeout: 15_000 });
|
||||
await memberRole.selectOption({ index: 1 }).catch(() => {});
|
||||
await page
|
||||
.locator('[data-testid^="share-dialog-member-notify-"]')
|
||||
.first()
|
||||
.click({ timeout: 3_000 })
|
||||
.catch(() => {});
|
||||
await page.getByTestId('share-dialog-close-btn').click();
|
||||
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
|
||||
|
||||
// Manage the grant from /shared. Scope to our folder's lane when present so
|
||||
// we drive the *user* grant menu (role / notify / expiry / remove); the menu
|
||||
// closes after each action, so reopen the kebab between steps.
|
||||
await page.goto('/shared');
|
||||
const lane = page.locator('section.ms-lane').filter({ hasText: folderName });
|
||||
const scope = (await lane.count()) > 0 ? lane.first() : page.locator('body');
|
||||
const kebab = scope.locator('[data-testid^="shared-kebab-"]').first();
|
||||
await expect(kebab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await kebab.click();
|
||||
await scope.locator('[data-testid^="shared-notify-"]').first().click({ timeout: 2_000 }).catch(() => {});
|
||||
await kebab.click().catch(() => {});
|
||||
await scope.locator('[data-testid^="shared-role-"]').first().click({ timeout: 2_000 }).catch(() => {});
|
||||
await kebab.click().catch(() => {});
|
||||
await scope
|
||||
.locator('[data-testid^="shared-expiry-"][data-testid$="-input"]')
|
||||
.first()
|
||||
.fill('2031-06-01', { timeout: 2_000 })
|
||||
.catch(() => {});
|
||||
await kebab.click().catch(() => {});
|
||||
await scope
|
||||
.locator('[data-testid^="shared-remove-access-"]')
|
||||
.first()
|
||||
.click({ timeout: 2_000 })
|
||||
.catch(() => {});
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
});
|
||||
|
||||
test('share a folder with a group', async ({ page }) => {
|
||||
const groupName = await apiCreateGroup(page, uniq('shareg'));
|
||||
const folderName = uniq('GroupShare');
|
||||
const parent = await apiCreateFolder(page, uniq('GroupShareHost'));
|
||||
await apiCreateFolder(page, folderName, parent.id);
|
||||
|
||||
await page.goto(`/files/${parent.id}`);
|
||||
await expect(page.getByTestId(folderName)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(folderName).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-share-item').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
await page.getByTestId('share-dialog-people-tab').click();
|
||||
|
||||
// Search the group and add it as a member.
|
||||
await page.getByTestId('share-dialog-search-input').fill(groupName);
|
||||
const groupResult = page.locator('[data-testid^="share-dialog-result-group-"]').first();
|
||||
await expect(groupResult).toBeVisible({ timeout: 15_000 });
|
||||
await groupResult.click();
|
||||
|
||||
await expect(page.locator('[data-testid^="share-dialog-member-"]').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId('share-dialog-close-btn').click();
|
||||
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Shared route — create a public link via the share dialog, then view it in
|
||||
* /shared and open a grant's kebab menu. Exercises the shared page (lanes,
|
||||
* rows, menu) plus the grants/shares endpoints.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('a created link appears in /shared with an openable menu', async ({ page }) => {
|
||||
const name = uniq('Shared');
|
||||
// Isolate in a fresh parent (the /files root accumulates 100s of folders
|
||||
// across the suite and its virtualised list won't render an arbitrary one).
|
||||
const parent = await apiCreateFolder(page, uniq('SharedHost'));
|
||||
await apiCreateFolder(page, name, parent.id);
|
||||
|
||||
// Create a public link through the share dialog.
|
||||
await page.goto(`/files/${parent.id}`);
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(name).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-share-item').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
await page.getByTestId('share-dialog-link-tab').click();
|
||||
await page.getByTestId('share-dialog-create-btn').click();
|
||||
await expect(page.locator('[data-testid^="share-dialog-link-delete-btn-"]').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId('share-dialog-close-btn').click();
|
||||
|
||||
// It now shows up under My shares; open the first grant's kebab menu.
|
||||
await page.goto('/shared');
|
||||
// Load more grants if the pager is shown (covers cursor pagination).
|
||||
await page.getByTestId('shared-load-more-btn').click({ timeout: 2_000 }).catch(() => {});
|
||||
|
||||
const kebab = page.locator('[data-testid^="shared-kebab-"]').first();
|
||||
await expect(kebab).toBeVisible({ timeout: 15_000 });
|
||||
await kebab.click();
|
||||
await expect(page.locator('[data-testid^="shared-menu-"]').first()).toBeVisible();
|
||||
|
||||
// Exercise the link-grant menu actions, then delete the link.
|
||||
await page
|
||||
.locator('[data-testid^="shared-link-expiry-"]')
|
||||
.first()
|
||||
.fill('2031-01-01', { timeout: 3_000 })
|
||||
.catch(() => {});
|
||||
await page
|
||||
.locator('[data-testid^="shared-menu-copy-link-"]')
|
||||
.first()
|
||||
.click({ timeout: 3_000 })
|
||||
.catch(() => {});
|
||||
await page
|
||||
.locator('[data-testid^="shared-delete-link-"]')
|
||||
.first()
|
||||
.click({ timeout: 3_000 })
|
||||
.catch(() => {});
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* ShareDialog coverage — opened from the files context menu. Exercises the
|
||||
* link tab (create a public link) and the people tab search, which pulls in
|
||||
* the share / shares / grants / recipients endpoint modules.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the named folder inside a *fresh* parent and open it, so the folder is
|
||||
* the only row (the shared `/files` root accumulates hundreds of folders across
|
||||
* the suite and its virtualised list won't render an arbitrary one), then open
|
||||
* its share dialog from the context menu.
|
||||
*/
|
||||
async function openShareDialog(page: import('@playwright/test').Page, name: string): Promise<void> {
|
||||
const parent = await apiCreateFolder(page, uniq('ShareHost'));
|
||||
await apiCreateFolder(page, name, parent.id);
|
||||
await page.goto(`/files/${parent.id}`);
|
||||
await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(name).click({ button: 'right' });
|
||||
await page.getByTestId('files-ctx-share-item').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
}
|
||||
|
||||
test('create a public link via the share dialog', async ({ page }) => {
|
||||
const name = uniq('Share');
|
||||
await openShareDialog(page, name);
|
||||
|
||||
// Link tab → fill name/password/expiry, then create a link.
|
||||
await page.getByTestId('share-dialog-link-tab').click();
|
||||
await page.getByTestId('share-dialog-link-name-input').fill('e2e link');
|
||||
await page.getByTestId('share-dialog-link-password-input').fill('secret123', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('share-dialog-link-expires-input').fill('2031-01-01', { timeout: 2_000 }).catch(() => {});
|
||||
await page.getByTestId('share-dialog-create-btn').click();
|
||||
|
||||
// A created link exposes copy/delete buttons keyed by its id.
|
||||
const del = page.locator('[data-testid^="share-dialog-link-delete-btn-"]').first();
|
||||
await expect(del).toBeVisible({ timeout: 15_000 });
|
||||
await page.locator('[data-testid^="share-dialog-link-copy-btn-"]').first().click({ timeout: 2_000 }).catch(() => {});
|
||||
|
||||
// Delete the link we just made.
|
||||
await del.click();
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
|
||||
await page.getByTestId('share-dialog-close-btn').click();
|
||||
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('people tab search runs in the share dialog', async ({ page }) => {
|
||||
const name = uniq('ShareP');
|
||||
await openShareDialog(page, name);
|
||||
|
||||
await page.getByTestId('share-dialog-people-tab').click();
|
||||
await page.getByTestId('share-dialog-search-input').fill('admin');
|
||||
// Let the debounced search fire (covers the recipients endpoint).
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.getByTestId('share-dialog-close-btn').click();
|
||||
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('invite an external user by email from the share dialog', async ({ page }) => {
|
||||
const name = uniq('ShareE');
|
||||
await openShareDialog(page, name);
|
||||
await page.getByTestId('share-dialog-people-tab').click();
|
||||
|
||||
// Typing an email surfaces a synthetic "invite by email" result.
|
||||
const email = `invitee-${Date.now()}@example.test`;
|
||||
await page.getByTestId('share-dialog-search-input').fill(email);
|
||||
const inviteRow = page.locator('[data-testid^="share-dialog-result-email-"]').first();
|
||||
await expect(inviteRow).toBeVisible({ timeout: 15_000 });
|
||||
await inviteRow.click();
|
||||
|
||||
// The invitee becomes a pending member.
|
||||
await expect(page.locator('[data-testid^="share-dialog-member-"]').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId('share-dialog-close-btn').click();
|
||||
await expect(page.getByTestId('share-dialog')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from './coverage-helpers';
|
||||
import { apiLogin, apiCreateFolder, apiTrashFolder } from '../scenarios/helpers';
|
||||
|
||||
/**
|
||||
* Trash flow — restore and permanently delete items from /trash. The folder is
|
||||
* trashed via the API (the /files root accumulates hundreds of folders across
|
||||
* the suite and its virtualised list won't render a specific one), and the
|
||||
* trash is emptied first so our item is the only — and thus visible — row.
|
||||
*/
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
});
|
||||
|
||||
function uniq(p: string): string {
|
||||
return `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test('restore an item from trash', async ({ page }) => {
|
||||
// Ensure the trash is non-empty, then restore the first item (the trash list
|
||||
// accumulates across the suite, so we exercise the endpoint on whatever is
|
||||
// first rather than hunting a specific virtualised row).
|
||||
const folder = await apiCreateFolder(page, uniq('Trash'));
|
||||
await apiTrashFolder(page, folder.id);
|
||||
|
||||
await page.goto('/trash');
|
||||
// Row action buttons are CSS hover-revealed (display:none until hover), so
|
||||
// assert they're attached and dispatch the click directly.
|
||||
const restore = page.locator('[data-testid^="trash-restore-btn-"]').first();
|
||||
await expect(restore).toBeAttached({ timeout: 15_000 });
|
||||
await restore.dispatchEvent('click');
|
||||
// The restore ran without error and the app is still mounted.
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('permanently delete an item from trash', async ({ page }) => {
|
||||
const folder = await apiCreateFolder(page, uniq('Purge'));
|
||||
await apiTrashFolder(page, folder.id);
|
||||
|
||||
await page.goto('/trash');
|
||||
const del = page.locator('[data-testid^="trash-delete-btn-"]').first();
|
||||
await expect(del).toBeAttached({ timeout: 15_000 });
|
||||
await del.dispatchEvent('click');
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('empty the trash from the toolbar', async ({ page }) => {
|
||||
// Seed a trashed item so the empty-trash button is shown, then empty via UI.
|
||||
const folder = await apiCreateFolder(page, uniq('EmptyMe'));
|
||||
await apiTrashFolder(page, folder.id);
|
||||
|
||||
await page.goto('/trash');
|
||||
await expect(page.getByTestId('trash-empty-btn')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('trash-empty-btn').click();
|
||||
const confirm = page.getByTestId('dialog-host-confirm-btn');
|
||||
if (await confirm.isVisible().catch(() => false)) await confirm.click();
|
||||
await expect(page.locator('[data-testid^="trash-restore-btn-"]')).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user