From 8f1dd1557ee91fc7b5ef23278512d99875117af2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:06:33 +0000 Subject: [PATCH] Add client-side Playwright tests and CI integration - Created `tests/playwright` directory with Playwright configuration. - Implemented E2E tests for directory listing, file upload, token validation, and client-side size limits. - Configured Playwright to launch the Rust server automatically via `webServer`. - Updated `.github/workflows/ci.yml` to include Node.js setup and run Playwright tests in CI. --- .github/workflows/ci.yml | 20 +++++ tests/playwright/.gitignore | 4 + tests/playwright/package-lock.json | 96 +++++++++++++++++++++ tests/playwright/package.json | 12 +++ tests/playwright/playwright.config.ts | 31 +++++++ tests/playwright/specs/lanshare.spec.ts | 109 ++++++++++++++++++++++++ 6 files changed, 272 insertions(+) create mode 100644 tests/playwright/.gitignore create mode 100644 tests/playwright/package-lock.json create mode 100644 tests/playwright/package.json create mode 100644 tests/playwright/playwright.config.ts create mode 100644 tests/playwright/specs/lanshare.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ed161..a3cd96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,23 @@ jobs: - name: Run tests run: cargo test --verbose + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Playwright Dependencies + run: | + cd tests/playwright + npm ci + + - name: Install Playwright Browsers + run: | + cd tests/playwright + npx playwright install --with-deps + + - name: Run Playwright Tests + run: | + cd tests/playwright + npx playwright test diff --git a/tests/playwright/.gitignore b/tests/playwright/.gitignore new file mode 100644 index 0000000..5e7ef5c --- /dev/null +++ b/tests/playwright/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +test-data/ diff --git a/tests/playwright/package-lock.json b/tests/playwright/package-lock.json new file mode 100644 index 0000000..8193f77 --- /dev/null +++ b/tests/playwright/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "lanshare-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lanshare-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.41.0", + "@types/node": "^20.11.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/playwright/package.json b/tests/playwright/package.json new file mode 100644 index 0000000..1a58f1e --- /dev/null +++ b/tests/playwright/package.json @@ -0,0 +1,12 @@ +{ + "name": "lanshare-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.41.0", + "@types/node": "^20.11.0" + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts new file mode 100644 index 0000000..bb51610 --- /dev/null +++ b/tests/playwright/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +export default defineConfig({ + testDir: './specs', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'line', + use: { + baseURL: 'http://127.0.0.1:9000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // Run cargo run from the repo root + // We use a specific test root and token + command: 'cargo run -- --bind 127.0.0.1:9000 --root ./tests/playwright/test-data --token secret-token --max-file-bytes 100 --overwrite deny', + cwd: '../../', // Go up two levels from tests/playwright to repo root + url: 'http://127.0.0.1:9000', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/tests/playwright/specs/lanshare.spec.ts b/tests/playwright/specs/lanshare.spec.ts new file mode 100644 index 0000000..482f213 --- /dev/null +++ b/tests/playwright/specs/lanshare.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DATA_DIR = path.join(__dirname, '../test-data'); + +// Clean up test data before and after tests +test.beforeEach(async () => { + // We can't easily clean the directory if the server is holding locks (Windows), + // but on Linux/Mac it's usually fine. The server creates the directory. + // We'll rely on the server creating it, but we can try to empty it if it exists. + // Note: The server is started by the global webServer config. + + // Create a dummy file to see in the list + if (!fs.existsSync(TEST_DATA_DIR)) { + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + } + fs.writeFileSync(path.join(TEST_DATA_DIR, 'hello.txt'), 'Hello world'); +}); + +test.describe('Lanshare Client UI', () => { + + test('lists files in the directory', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle('File Server'); + + // Check for the file we created in beforeEach + const fileLink = page.getByRole('link', { name: 'hello.txt' }); + await expect(fileLink).toBeVisible(); + await expect(page.locator('code').first()).toHaveText('/'); // Current directory + }); + + test('uploads a file successfully with correct token', async ({ page }) => { + await page.goto('/'); + + // Create a temporary file to upload + const uploadFileName = 'upload-test.txt'; + const uploadFilePath = path.join(TEST_DATA_DIR, '..', uploadFileName); // Outside the served root + fs.writeFileSync(uploadFilePath, 'some content'); + + // Fill the token + await page.locator('#token-input').fill('secret-token'); + + // Select file + const fileInput = page.locator('#file-input'); + await fileInput.setInputFiles(uploadFilePath); + + // Click upload + await page.getByRole('button', { name: 'Upload' }).click(); + + // Wait for the upload to complete and page to reload. + // The client calls window.location.reload() immediately after setting success text, + // so checking for "Upload complete" is race-prone. + // Instead, we wait for the uploaded file to appear in the list. + + // Verify file is in the list + const uploadedLink = page.getByRole('link', { name: uploadFileName }); + await expect(uploadedLink).toBeVisible(); + + // Cleanup + fs.unlinkSync(uploadFilePath); + const destinationPath = path.join(TEST_DATA_DIR, uploadFileName); + if (fs.existsSync(destinationPath)) fs.unlinkSync(destinationPath); + }); + + test('fails to upload with incorrect token', async ({ page }) => { + await page.goto('/'); + + const uploadFileName = 'fail-test.txt'; + const uploadFilePath = path.join(TEST_DATA_DIR, '..', uploadFileName); + fs.writeFileSync(uploadFilePath, 'content'); + + // Fill WRONG token + await page.locator('#token-input').fill('wrong-token'); + + await page.locator('#file-input').setInputFiles(uploadFilePath); + await page.getByRole('button', { name: 'Upload' }).click(); + + // Wait for failure status + // The server returns 401 "missing/invalid upload token" + await expect(page.locator('#upload-status')).toContainText('Upload failed for fail-test.txt: missing/invalid upload token'); + + // Cleanup + fs.unlinkSync(uploadFilePath); + }); + + test('client-side prevents uploading files larger than limit', async ({ page }) => { + // server configured with max 100 bytes + await page.goto('/'); + + const bigFileName = 'big.txt'; + const bigFilePath = path.join(TEST_DATA_DIR, '..', bigFileName); + // Create file > 100 bytes + const bigContent = 'a'.repeat(101); + fs.writeFileSync(bigFilePath, bigContent); + + await page.locator('#token-input').fill('secret-token'); + await page.locator('#file-input').setInputFiles(bigFilePath); + await page.getByRole('button', { name: 'Upload' }).click(); + + // Expect client-side blocking message + // JS logic: setStatus('Upload blocked for ' + file.name + ': file is ' + file.size + ' bytes (max ' + maxBytes + ').'); + await expect(page.locator('#upload-status')).toContainText('Upload blocked for big.txt'); + await expect(page.locator('#upload-status')).toContainText('max 100'); + + // Cleanup + fs.unlinkSync(bigFilePath); + }); +});