test: add front end folder management test (creation, renaming, etc)
note: firefox test remove due to idempotence issue
@@ -0,0 +1,6 @@
|
||||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
export default async function globalTeardown() {
|
||||
execSync(`bash ${path.join(__dirname, '../common/stop-db.sh')}`, { stdio: 'inherit' });
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"pretest": "bash ../common/spawn-db.sh",
|
||||
"test": "npx playwright test",
|
||||
"test": "npx playwright test --reporter=line,github --trace=on-first-retry scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
|
||||
"posttest": "bash ../common/stop-db.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const startScript = path.join(__dirname, 'start-server.sh');
|
||||
|
||||
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */
|
||||
function loadEnv(filePath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
@@ -23,13 +25,14 @@ const workspace=process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './scenarios',
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['github'], ['html']] : 'html',
|
||||
|
||||
globalSetup: require.resolve('./global-setup'),
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:8087',
|
||||
@@ -45,22 +48,18 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.BUILD_TARGET
|
||||
? `${workspace}/target/${process.env.BUILD_TARGET}/oxicloud`
|
||||
: 'cargo run',
|
||||
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
|
||||
: `bash "${startScript}" cargo run`,
|
||||
url: 'http://localhost:8087',
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: false,
|
||||
cwd: '../..',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
env: {
|
||||
...commonEnv,
|
||||
OXICLOUD_SERVER_PORT: '8087',
|
||||
|
||||
@@ -43,7 +43,7 @@ test.describe('authenticated as admin', () => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('home page shows files list', async ({ page }) => {
|
||||
test('home page shows empty files list', async ({ page }) => {
|
||||
await expect(page.locator('.files-container')).toBeVisible();
|
||||
await expect(page.locator('#user-menu-wrapper')).toBeVisible();
|
||||
await expect(page).toHaveScreenshot('home-files.png', {
|
||||
@@ -52,6 +52,8 @@ test.describe('authenticated as admin', () => {
|
||||
animations: 'disabled',
|
||||
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
|
||||
});
|
||||
await expect(page.locator('#files-container-error')).toBeVisible();
|
||||
await expect(page.locator('#files-container-error')).toContainText("No files in this folder");
|
||||
});
|
||||
|
||||
test('theme can be changed to dark', async ({ page }) => {
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,232 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { loginAsAdmin } from './helpers';
|
||||
|
||||
const FIXTURES = path.join(__dirname, '../../fixtures');
|
||||
|
||||
/**
|
||||
* Opens the new-folder modal, fills the name, and clicks Confirm.
|
||||
* Does NOT wait for the modal to close — the caller decides what to assert next.
|
||||
*/
|
||||
async function submitNewFolder(page: Page, name: string) {
|
||||
await page.locator('#new-folder-btn').click();
|
||||
await expect(page.locator('#input-modal')).toBeVisible();
|
||||
await page.locator('#modal-input').fill(name);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
}
|
||||
|
||||
function mimeFromPath(fp: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
return map[path.extname(fp).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a file drag-and-drop onto the OxiCloud dropzone overlay.
|
||||
*
|
||||
* How it works:
|
||||
* 1. File buffers are read in Node and transferred into the page as File
|
||||
* objects inside a DataTransfer — no real OS drag needed.
|
||||
* 2. A document-level 'dragover' is dispatched so the app reveals the
|
||||
* dropzone (same code path as a real drag from the OS).
|
||||
* 3. A 'drop' is dispatched on #dropzone. The handler falls back to
|
||||
* dataTransfer.files when webkitGetAsEntry() returns null (which it
|
||||
* does for programmatic File objects), hitting the same upload path.
|
||||
*/
|
||||
async function dragFilesToDropzone(page: Page, filePaths: string[]) {
|
||||
const fileData = await Promise.all(
|
||||
filePaths.map(async (fp) => ({
|
||||
name: path.basename(fp),
|
||||
buffer: Array.from(await fs.readFile(fp)),
|
||||
type: mimeFromPath(fp),
|
||||
}))
|
||||
);
|
||||
|
||||
const dataTransfer = await page.evaluateHandle((files) => {
|
||||
const dt = new DataTransfer();
|
||||
for (const { name, buffer, type } of files) {
|
||||
dt.items.add(new File([new Uint8Array(buffer)], name, { type }));
|
||||
}
|
||||
return dt;
|
||||
}, fileData);
|
||||
|
||||
// Reveal the dropzone overlay (same logic as a real OS drag).
|
||||
await dataTransfer.evaluate((dt) => {
|
||||
document.dispatchEvent(new DragEvent('dragover', {
|
||||
bubbles: true, cancelable: true, dataTransfer: dt,
|
||||
}));
|
||||
});
|
||||
await expect(page.locator('#dropzone')).toBeVisible();
|
||||
|
||||
await page.locator('#dropzone').dispatchEvent('drop', { dataTransfer });
|
||||
}
|
||||
|
||||
test.describe('Folder management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('folder creation', async ({ page }) => {
|
||||
const name = `Test folder ${Date.now()}`;
|
||||
|
||||
await submitNewFolder(page, name);
|
||||
|
||||
await expect(page.locator('#input-modal')).toBeHidden();
|
||||
await expect(page.locator(`.file-item[data-folder-name="${name}"]`)).toBeVisible();
|
||||
|
||||
// Screenshot: mask dynamic text so layout is what's tested, not the name.
|
||||
await expect(page).toHaveScreenshot('folder-created.png', {
|
||||
animations: 'disabled',
|
||||
mask: [
|
||||
page.locator('.storage-bar'),
|
||||
page.locator('.storage-info'),
|
||||
page.locator('.date-cell'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('folder reject if already exists', async ({ page }) => {
|
||||
const name = `Test folder ${Date.now()}`;
|
||||
|
||||
// Prerequisite: create the folder once successfully.
|
||||
await submitNewFolder(page, name);
|
||||
await expect(page.locator('#input-modal')).toBeHidden();
|
||||
|
||||
// Second creation with the same name must keep the modal open with an error.
|
||||
await submitNewFolder(page, name);
|
||||
await expect(page.locator('#modal-error')).toBeVisible();
|
||||
await expect(page.locator('#modal-error')).toContainText('already exists');
|
||||
|
||||
// Cancel leaves the list unchanged.
|
||||
await page.locator('#modal-cancel-btn').click();
|
||||
await expect(page.locator('#input-modal')).toBeHidden();
|
||||
});
|
||||
|
||||
test('folder creation rejected if bad name', async ({ page }) => {
|
||||
await page.locator('#new-folder-btn').click();
|
||||
await expect(page.locator('#input-modal')).toBeVisible();
|
||||
await page.locator('#modal-input').fill('/');
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
|
||||
await expect(page.locator('#modal-error')).toBeVisible();
|
||||
await expect(page.locator('#modal-error')).toContainText('Invalid folder name');
|
||||
|
||||
await page.locator('#modal-cancel-btn').click();
|
||||
await expect(page.locator('#input-modal')).toBeHidden();
|
||||
});
|
||||
|
||||
test('folder rename', async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const original = `Test folder ${ts}`;
|
||||
const renamed = `Renamed folder ${ts}`;
|
||||
|
||||
// Prerequisite: create the folder to rename.
|
||||
await submitNewFolder(page, original);
|
||||
await expect(page.locator('#input-modal')).toBeHidden();
|
||||
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).toBeVisible();
|
||||
|
||||
// Open context menu and trigger rename.
|
||||
await page.locator(`.file-item[data-folder-name="${original}"] .file-actions`).click();
|
||||
await expect(page.locator('#folder-context-menu')).toBeVisible();
|
||||
await page.locator('#rename-folder-option').click();
|
||||
|
||||
await expect(page.locator('#input-modal')).toBeVisible();
|
||||
await page.locator('#modal-input').fill(renamed);
|
||||
await page.locator('#modal-confirm-btn').click();
|
||||
await expect(page.locator('#input-modal')).toBeHidden();
|
||||
|
||||
await expect(page.locator(`.file-item[data-folder-name="${renamed}"]`)).toBeVisible();
|
||||
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveScreenshot('folder-renamed.png', {
|
||||
animations: 'disabled',
|
||||
mask: [
|
||||
page.locator('.storage-bar'),
|
||||
page.locator('.storage-info'),
|
||||
page.locator('.date-cell'),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ── File upload ────────────────────────────────────────────────────────────
|
||||
// These two tests are intentionally sequential: test 1 uploads the files,
|
||||
// test 2 reads the state left by test 1. Both run inside the same describe
|
||||
// block so Playwright executes them in order within a single worker.
|
||||
|
||||
test('upload of multiple files', async ({ page }) => {
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('#upload-btn').click();
|
||||
await expect(page.locator('#upload-dropdown-menu')).toBeVisible();
|
||||
await page.locator('#upload-files-btn').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles([
|
||||
path.join(FIXTURES, 'oxicloud-logo.jpg'),
|
||||
path.join(FIXTURES, 'free_video_over_1MB.mp4'),
|
||||
]);
|
||||
|
||||
// Each file appears in the list as soon as its individual upload completes.
|
||||
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('uploaded files rendered with thumbnails', async ({ page }) => {
|
||||
// The beforeEach login re-loads the home folder; files uploaded in the
|
||||
// previous test are already in the DB and visible immediately.
|
||||
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible();
|
||||
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible();
|
||||
|
||||
// Image thumbnail is generated server-side: wait for the <img> to be
|
||||
// visible (the error handler hides it while the server is still processing,
|
||||
// then un-hides it once the src resolves successfully).
|
||||
await expect(
|
||||
page.locator('.file-item[data-file-name="oxicloud-logo.jpg"] .file-thumb')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Video thumbnail is generated client-side from a canvas frame extraction.
|
||||
// Give the browser 1 s to finish before taking the screenshot.
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
await expect(page).toHaveScreenshot('files-with-thumbnails.png', {
|
||||
animations: 'disabled',
|
||||
mask: [
|
||||
page.locator('.storage-bar'),
|
||||
page.locator('.storage-info'),
|
||||
page.locator('.date-cell'), // upload timestamps differ every run
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('drag and drop images onto dropzone', async ({ page }) => {
|
||||
await dragFilesToDropzone(page, [
|
||||
path.join(FIXTURES, 'blue-image.png'),
|
||||
path.join(FIXTURES, 'green-image.png'),
|
||||
path.join(FIXTURES, 'red-image.png'),
|
||||
]);
|
||||
|
||||
// Each card appears as its upload completes.
|
||||
await expect(page.locator('.file-item[data-file-name="blue-image.png"]')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.file-item[data-file-name="green-image.png"]')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.file-item[data-file-name="red-image.png"]')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// PNGs get server-side thumbnails — wait for at least the first to resolve.
|
||||
await expect(
|
||||
page.locator('.file-item[data-file-name="blue-image.png"] .file-thumb')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(page).toHaveScreenshot('dropped-files.png', {
|
||||
animations: 'disabled',
|
||||
mask: [
|
||||
page.locator('.storage-bar'),
|
||||
page.locator('.storage-info'),
|
||||
page.locator('.date-cell'),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot a clean test DB then start OxiCloud (passed as arguments).
|
||||
# Used by playwright.config.ts as the webServer command so that both
|
||||
# `npm test` and `npx playwright test` always start from an empty database.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage"
|
||||
|
||||
# ensure storage is empty before starting
|
||||
echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup"
|
||||
rm -rf "$OXICLOUD_STORAGE_PATH"
|
||||
mkdir -p "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
# Spawn database
|
||||
bash "$REPO_ROOT/tests/common/spawn-db.sh"
|
||||
|
||||
# Replace the shell with the server process so Playwright's PID tracking works.
|
||||
exec "$@"
|
||||