chore(e2e): remove old playwright test (wired to vanilla JS)
@@ -1,45 +0,0 @@
|
||||
name: Playwright — Update snapshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-snapshots:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tests/e2e
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build OxiCloud (release)
|
||||
working-directory: .
|
||||
run: cargo build --release
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Update snapshots
|
||||
run: npm test -- --update-snapshots=all
|
||||
env:
|
||||
BUILD_TARGET: release
|
||||
|
||||
- name: Commit updated snapshots
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "test(e2e): update playwright linux snapshots"
|
||||
file_pattern: "tests/e2e/scenarios/**/*-linux.png"
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_STATIC_PATH=./static
|
||||
# `OXICLOUD_STATIC_PATH` intentionally NOT set — every remaining
|
||||
# test suite either serves the built SvelteKit SPA from
|
||||
# `static-dist/` (coverage suite via `start-server-spa.sh`) or
|
||||
# doesn't need static assets at all (Hurl API / WebDAV / CalDAV
|
||||
# suites). The legacy `./static` vanilla frontend was retired.
|
||||
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
|
||||
OXICLOUD_ENABLE_AUTH=true
|
||||
OXICLOUD_ENABLE_TRASH=true
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"pretest": "bash ../common/spawn-db.sh",
|
||||
"test": "FORCE_COLOR=true npx playwright test scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
|
||||
"posttest": "bash ../common/stop-db.sh",
|
||||
"test:containers": "OXICLOUD_E2E_CONTAINERS=1 FORCE_COLOR=true npx playwright test -c playwright.containers.config.ts",
|
||||
"build:spa": "cd ../../frontend && COVERAGE=1 VITE_E2E=1 npm run build",
|
||||
"test:coverage": "FORCE_COLOR=true npx playwright test -c playwright.coverage.config.ts",
|
||||
"coverage:report": "node coverage-report.cjs e2e",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
import { loadEnv } from './load-env';
|
||||
|
||||
const startScript = path.join(__dirname, 'start-server.sh');
|
||||
|
||||
const commonEnv = loadEnv(path.join(__dirname, '../common/server.env'));
|
||||
|
||||
console.log(`starting playwright with env BUILD_TARGET=${process.env.BUILD_TARGET ?? "debug"}`);
|
||||
|
||||
const workspace=process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './scenarios',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [ ['list'], ['html']],
|
||||
|
||||
globalSetup: require.resolve('./global-setup'),
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
|
||||
use: {
|
||||
// 127.0.0.1, not `localhost`: the server binds IPv4 (127.0.0.1:8087) and
|
||||
// on CI runners `localhost` resolves to ::1 (IPv6) first, so requests get
|
||||
// ECONNREFUSED and the webServer readiness check below times out.
|
||||
baseURL: 'http://127.0.0.1:8087',
|
||||
trace: 'on-first-retry',
|
||||
headless: true,
|
||||
// take a screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
// Tile/file rows expose a `data-testid` of the item name (kept only in e2e
|
||||
// builds via VITE_E2E); makes getByTestId + codegen prefer stable selectors.
|
||||
testIdAttribute: 'data-testid',
|
||||
},
|
||||
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.BUILD_TARGET
|
||||
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
|
||||
: `bash "${startScript}" cargo run`,
|
||||
// Poll the dedicated readiness probe, not `/`: `/ready` returns 200 as soon
|
||||
// as the DB pool is live, whereas `/` depends on the SPA build being present
|
||||
// and can 404 (Playwright only treats 2xx/3xx/400-403 as "ready").
|
||||
url: 'http://127.0.0.1:8087/ready',
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: false,
|
||||
cwd: '../..',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
env: {
|
||||
...commonEnv,
|
||||
OXICLOUD_SERVER_PORT: '8087',
|
||||
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
|
||||
// Verbose startup so a CI webServer-readiness timeout shows where the
|
||||
// server stalls (DB connect, migrations, bind) instead of nothing.
|
||||
RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info',
|
||||
// OPAQUE + DPoP inherited from `../common/server.env`:
|
||||
// OXICLOUD_AUTH_OPAQUE_MODE=migrate (Phase 2 silent-migration
|
||||
// on first legacy login, Phase 4 refusal thereafter)
|
||||
// OXICLOUD_DPOP_MODE=required (verify every proof;
|
||||
// unbound sessions still exempt per Gate 5 design)
|
||||
//
|
||||
// Known failure surfaces under `DPOP=required`:
|
||||
// * Node-side `page.request.*` helpers can't sign proofs
|
||||
// → 401 on state-changing calls. Task #47 rewrites those
|
||||
// through `page.evaluate` so signing happens in-browser.
|
||||
// * Browser-direct content GETs (img src, a href, video src)
|
||||
// also can't sign — Gate C content-serve allowlist in
|
||||
// `middleware/dpop.rs` exempts the known paths.
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* Testcontainers e2e config (Option D): every worker boots its own isolated
|
||||
* DB + OxiCloud app container (Svelte SPA baked in) via the worker-scoped
|
||||
* `stack` fixture in `scenarios/helpers.ts`.
|
||||
*
|
||||
* Differences from `playwright.config.ts` (legacy single-server flow):
|
||||
* - No `webServer` — the app is a container started per worker.
|
||||
* - No `globalSetup` — each worker seeds its own admin in the fixture.
|
||||
* - No `use.baseURL` — the fixture supplies a per-worker random port.
|
||||
* - `fullyParallel` + N workers — stacks are fully isolated, so parallel.
|
||||
*
|
||||
* Run with: OXICLOUD_E2E_CONTAINERS=1 playwright test -c playwright.containers.config.ts
|
||||
* Set $OXICLOUD_IMAGE to a prebuilt tag to skip the per-run Dockerfile build.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './scenarios',
|
||||
// The recorder harnesses in scenarios/codegen/ call page.pause() and are run
|
||||
// only via `just front-codegen` (playwright.codegen.config.ts) — never as
|
||||
// part of the e2e suite.
|
||||
testIgnore: ['**/codegen/**'],
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
// One DB + one app container per worker — cap so we don't exhaust Docker.
|
||||
workers: process.env.CI ? 2 : Math.max(1, Math.floor(os.cpus().length / 4)),
|
||||
// Real container app is slower than a local cargo process; give actions room.
|
||||
timeout: 60_000,
|
||||
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [['list'], ['html']],
|
||||
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
// Tile/file rows expose a `data-testid` of the item name (kept only in e2e
|
||||
// builds via VITE_E2E); makes getByTestId + codegen prefer stable selectors.
|
||||
testIdAttribute: 'data-testid',
|
||||
// On NixOS (and other distros where Playwright's downloaded chromium can't
|
||||
// run due to the generic dynamic linker), point at a system/Nix chromium
|
||||
// via PW_CHROMIUM_PATH. Unset → use Playwright's bundled browser (CI).
|
||||
launchOptions: process.env.PW_CHROMIUM_PATH
|
||||
? { executablePath: process.env.PW_CHROMIUM_PATH }
|
||||
: {},
|
||||
},
|
||||
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers';
|
||||
|
||||
test('has OxiCloud title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/OxiCloud/);
|
||||
});
|
||||
|
||||
test('language selector > choose EN > reach login page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('#language-panel')).toBeVisible();
|
||||
await expect(page.getByText('Select your language to continue')).toBeVisible();
|
||||
await expect(page.locator('#lang-picker-name')).toHaveText('English');
|
||||
|
||||
await expect(page.locator('#language-panel')).toHaveScreenshot('language-selector.png');
|
||||
|
||||
await page.locator('#language-continue').click();
|
||||
|
||||
await expect(page.locator('#login-panel')).toBeVisible();
|
||||
await expect(page.locator('#language-panel')).toBeHidden();
|
||||
|
||||
await expect(page.locator('#login-panel')).toHaveScreenshot('login-panel.png');
|
||||
});
|
||||
|
||||
test('login with wrong password is rejected', async ({ page }) => {
|
||||
await goToLoginPage(page);
|
||||
|
||||
await page.locator('#login-username').fill(TEST_ADMIN.username);
|
||||
await page.locator('#login-password').fill('definitely-wrong-password');
|
||||
await page.locator('#login-submit').click();
|
||||
|
||||
const loginError = page.locator('#login-error');
|
||||
await expect(loginError).toBeVisible();
|
||||
await expect(loginError).toContainText('Authentication error (403): Forbidden');
|
||||
|
||||
await expect(page.locator('#login-panel')).toBeVisible();
|
||||
await expect(page.locator('#login-panel')).toHaveScreenshot('login-panel-error.png');
|
||||
});
|
||||
|
||||
test.describe('authenticated as admin', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(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', {
|
||||
// ignore this div (max value may change)
|
||||
// FIXME: ensure same max capacity from server during test
|
||||
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 }) => {
|
||||
await page.locator('#user-avatar-btn').click();
|
||||
await expect(page.locator('#user-menu')).toBeVisible();
|
||||
|
||||
// The appearance row is now a 3-option segmented control
|
||||
// (Light / Like OS / Dark). Each option carries a `data-mode` attribute.
|
||||
await page.locator('.theme-segmented__opt[data-mode="dark"]').click();
|
||||
|
||||
// html element must carry data-color-scheme="dark" (new attribute).
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', 'dark');
|
||||
|
||||
// localStorage must persist the choice.
|
||||
const theme = await page.evaluate(() => localStorage.getItem('oxicloud_theme'));
|
||||
expect(theme).toBe('dark');
|
||||
|
||||
await expect(page).toHaveScreenshot('home-files-darktheme.png', {
|
||||
// ignore this div (max value may change)
|
||||
// FIXME: ensure same max capacity from server during test
|
||||
animations: 'disabled',
|
||||
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
|
||||
});
|
||||
|
||||
// Switch back to light via the Light option.
|
||||
await page.locator('.theme-segmented__opt[data-mode="light"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', 'light');
|
||||
|
||||
const themeAfter = await page.evaluate(() => localStorage.getItem('oxicloud_theme'));
|
||||
expect(themeAfter).toBe('light');
|
||||
|
||||
await expect(page).toHaveScreenshot('home-files-lightheme.png', {
|
||||
// ignore this div (max value may change)
|
||||
// FIXME: ensure same max capacity from server during test
|
||||
animations: 'disabled',
|
||||
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 37 KiB |
@@ -1,231 +0,0 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { test, 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 creation`;
|
||||
|
||||
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 existing folder`;
|
||||
|
||||
// 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 original = `Test folder`;
|
||||
const renamed = `Renamed folders`;
|
||||
|
||||
// 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'),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 120 KiB |
@@ -1,17 +0,0 @@
|
||||
import { test, apiLogin, seedFilesAndFolders } from './helpers';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
test('TextFilesShowContents', async ({ page }) => {
|
||||
await apiLogin(page);
|
||||
await seedFilesAndFolders(page);
|
||||
await page.goto('/');
|
||||
|
||||
// recorded steps
|
||||
await page.getByTestId('Documents').click();
|
||||
await page.getByTestId('notes.txt').click();
|
||||
await expect(page.locator('pre')).toContainText('Hello from the codegen seed. Line two.');
|
||||
await page.getByTestId('file-viewer-close-btn').click();
|
||||
await page.getByTestId('README.md').click();
|
||||
await expect(page.locator('pre')).toContainText('# Seeded A **markdown** file for the file browser.');
|
||||
await page.getByTestId('file-viewer-close-btn').click();
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
# Codegen recorder templates
|
||||
|
||||
Each file here is a **starting point** for `just front-codegen`. It boots an
|
||||
isolated container stack, runs your setup, then opens the Playwright Inspector
|
||||
so you record from a known state. The file name is what shows up in the menu —
|
||||
drop a new `*.spec.ts` here and it appears automatically.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```ts
|
||||
import { test, apiLogin } from '../helpers';
|
||||
|
||||
test('codegen: <name>', async ({ page }) => {
|
||||
test.setTimeout(0); // no timeout — recorder stays open until you close it
|
||||
await apiLogin(page); // setup: get to the state you want to record from
|
||||
await page.goto('/files');
|
||||
await page.pause(); // MUST be last — opens the Inspector
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Import `test` (and any helpers) from `../helpers` — **not** `@playwright/test`.
|
||||
That wires in the container-stack fixture and the JS-error guard.
|
||||
- Set `test.setTimeout(0)` so the recorder doesn't time out while you work.
|
||||
- End with `await page.pause()`. Everything **before** it runs first, so put as
|
||||
much setup as you like there (log in, open a folder, start an upload, …) and
|
||||
you'll record the continuation from that state.
|
||||
- Use the helpers for setup: `apiLogin(page)` signs in via the API (no UI
|
||||
clicks, selector-independent). Skip it for an anonymous/login flow.
|
||||
|
||||
## What carries into the saved spec
|
||||
|
||||
When you save a recording, the setup lines (everything before `page.pause()`,
|
||||
minus `test.setTimeout`) are copied into the generated `scenarios/<name>.spec.ts`,
|
||||
then your recorded steps are spliced in. So a recorder's setup == the saved
|
||||
test's setup — keep it to the state you want every recording from this template
|
||||
to start in.
|
||||
|
||||
## Seeding content before you record
|
||||
|
||||
To record flows that need existing files/folders (move, copy, delete,
|
||||
multi-select, previews, sorting by type), seed them via the API in the setup —
|
||||
no UI clicks, just like `apiLogin`. The `authed-files` template does this:
|
||||
|
||||
```ts
|
||||
import { test, apiLogin, seedFilesAndFolders } from '../helpers';
|
||||
|
||||
test('codegen: authed-files', async ({ page }) => {
|
||||
test.setTimeout(0);
|
||||
await apiLogin(page);
|
||||
await seedFilesAndFolders(page); // Documents/, Documents/Reports/, Images/ + files of each type
|
||||
await page.goto('/');
|
||||
await page.pause();
|
||||
});
|
||||
```
|
||||
|
||||
`helpers.ts` exposes the building blocks: `apiCreateFolder`, `apiUploadFile`,
|
||||
`SAMPLE_FILES` (text / markdown / JSON / CSV / PNG / PDF), and the
|
||||
`seedFilesAndFolders` convenience that lays down a small mixed-type tree.
|
||||
**Remember:** the saved spec inherits this setup, so the recorded selectors only
|
||||
have content to act on if `seedFilesAndFolders(page)` runs there too — it does,
|
||||
because the template's setup is copied into the saved test.
|
||||
|
||||
## Add one
|
||||
|
||||
```sh
|
||||
cp authed.spec.ts my-start.spec.ts # edit the setup, keep page.pause() last
|
||||
just front-codegen # "my-start" is now in the menu
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
import { test } from '../helpers';
|
||||
|
||||
/**
|
||||
* Codegen recorder — START: anonymous at the app root.
|
||||
*
|
||||
* Run via `just front-codegen` → pick "anon". No login; navigates to `/` and
|
||||
* lets the app route an unauthenticated visitor (currently it redirects to
|
||||
* /login?redirect=/). Use this to record the logged-out redirect behaviour or
|
||||
* a future public landing page. Click the Record ⏺ button to start generating.
|
||||
*/
|
||||
test('codegen: anon', async ({ page }) => {
|
||||
test.setTimeout(0); // recorder stays open until you close the Inspector
|
||||
await page.goto('/');
|
||||
await page.pause();
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { test, apiLogin, seedFilesAndFolders } from '../helpers';
|
||||
|
||||
/**
|
||||
* Codegen recorder — START: signed in, on a file browser already populated
|
||||
* with folders and files of different types.
|
||||
*
|
||||
* Builds on the "authed" template: same `apiLogin`, but also seeds a small
|
||||
* tree via the API before pausing, so you record flows that need existing
|
||||
* content (move/copy/delete, drag-drop, previews, multi-select, sorting by
|
||||
* type, …) without first creating it by hand.
|
||||
*
|
||||
* Seeded in the home folder (see `seedFilesAndFolders`):
|
||||
* config.json, pixel.png (root)
|
||||
* Documents/ → README.md, notes.txt
|
||||
* Documents/Reports/ → data.csv, sample.pdf
|
||||
* Images/ → pixel.png
|
||||
*
|
||||
* Run via `just front-codegen` → pick "authed-files". Click the Record ⏺
|
||||
* button in the Inspector to start generating; copy the code into a real
|
||||
* *.spec.ts (see scenarios/example.template.ts). Note the saved test must run
|
||||
* `seedFilesAndFolders(page)` in its setup too, or the recorded selectors
|
||||
* won't have anything to act on.
|
||||
*/
|
||||
test('codegen: authed-files', async ({ page }) => {
|
||||
test.setTimeout(0); // recorder stays open until you close the Inspector
|
||||
await apiLogin(page);
|
||||
await seedFilesAndFolders(page);
|
||||
await page.goto('/');
|
||||
await page.pause();
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { test, apiLogin } from '../helpers';
|
||||
|
||||
/**
|
||||
* Codegen recorder — START: signed in, on the trash view.
|
||||
*
|
||||
* Run via `just front-codegen` → pick "authed-trash". An example of a
|
||||
* deep-linked start point — copy this file (apiLogin + goto a route) to add
|
||||
* your own recorders; `just front-codegen` discovers every *.spec.ts here
|
||||
* automatically. Click the Record ⏺ button in the Inspector to start generating.
|
||||
*/
|
||||
test('codegen: authed-trash', async ({ page }) => {
|
||||
test.setTimeout(0); // recorder stays open until you close the Inspector
|
||||
await apiLogin(page);
|
||||
await page.goto('/trash');
|
||||
await page.pause();
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { test, apiLogin } from '../helpers';
|
||||
|
||||
/**
|
||||
* Codegen recorder — START: signed in, on the file browser.
|
||||
*
|
||||
* Run via `just front-codegen` → pick "authed". Boots an isolated container
|
||||
* stack, runs the setup below, then page.pause() opens the Inspector. Click
|
||||
* the Record ⏺ button to start generating; copy the code into a real
|
||||
* *.spec.ts (see scenarios/example.template.ts).
|
||||
*
|
||||
* Extend the setup to record from a deeper state (e.g. open a folder, start an
|
||||
* upload) — everything before page.pause() runs first, so you record the
|
||||
* continuation.
|
||||
*/
|
||||
test('codegen: authed', async ({ page }) => {
|
||||
test.setTimeout(0); // recorder stays open until you close the Inspector
|
||||
await apiLogin(page);
|
||||
await page.goto('/');
|
||||
await page.pause();
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { test } from '../helpers';
|
||||
|
||||
/**
|
||||
* Codegen recorder — START: the SPA sign-in screen (not authenticated).
|
||||
*
|
||||
* Run via `just front-codegen` → pick "login". Use this to record the login
|
||||
* flow itself. Click the Record ⏺ button in the Inspector to start generating.
|
||||
*/
|
||||
test('codegen: login', async ({ page }) => {
|
||||
test.setTimeout(0); // recorder stays open until you close the Inspector
|
||||
await page.goto('/login');
|
||||
await page.pause();
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Scaffold for a recorded spec — NOT collected by the runner (no `.spec`/`.test`
|
||||
* in the name). Turn a codegen recording into a real test:
|
||||
*
|
||||
* 1. cp scenarios/example.template.ts scenarios/<name>.spec.ts
|
||||
* 2. Match the setup to the codegen TEMPLATE you recorded from (below).
|
||||
* 3. Paste the recorded steps from the Playwright Inspector where marked.
|
||||
* 4. Add assertions.
|
||||
*
|
||||
* Run it with: npm run test:containers
|
||||
*/
|
||||
import { test, apiLogin } from './helpers';
|
||||
// import { expect } from '@playwright/test'; // ← uncomment when you add assertions
|
||||
|
||||
test('describe the behaviour under test', async ({ page }) => {
|
||||
// ── Setup: mirror your codegen TEMPLATE ──────────────────────────────────
|
||||
// template "authed" / "authed-*":
|
||||
await apiLogin(page);
|
||||
await page.goto('/');
|
||||
// template "login": (remove the two lines above) await page.goto('/login');
|
||||
// template "anon": (remove the two lines above) await page.goto('/');
|
||||
|
||||
// ── ▼ Paste recorded steps from the Inspector below ▼ ────────────────────
|
||||
|
||||
// ── ▲ End recorded steps ▲ ───────────────────────────────────────────────
|
||||
|
||||
// Assertions, e.g.:
|
||||
// await expect(page.getByRole('heading', { name: 'Files' })).toBeVisible();
|
||||
});
|
||||
@@ -1,468 +0,0 @@
|
||||
import { test as base, Page, expect } from '@playwright/test';
|
||||
import { startStack, Stack } from '../fixtures/oxicloud-stack';
|
||||
|
||||
/**
|
||||
* When `OXICLOUD_E2E_CONTAINERS=1`, each Playwright worker boots its own
|
||||
* isolated DB + app stack via Testcontainers (see `playwright.containers.
|
||||
* config.ts`). Otherwise the legacy single-server webServer flow is used
|
||||
* and this fixture is an inert pass-through.
|
||||
*/
|
||||
const USE_CONTAINERS = process.env.OXICLOUD_E2E_CONTAINERS === '1';
|
||||
|
||||
type WorkerFixtures = {
|
||||
/** The per-worker isolated stack, or `null` in the legacy webServer flow. */
|
||||
stack: Stack | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended `test` fixture with two responsibilities:
|
||||
*
|
||||
* 1. `stack` (worker-scoped) — in container mode, starts a dedicated
|
||||
* DB + app stack per worker, seeds its admin, and tears it down at
|
||||
* worker exit. The app instance is reused across every test the worker
|
||||
* runs, so container startup is paid once per worker, not per test.
|
||||
* 2. `page` — fails any test that produces an unhandled browser-side JS
|
||||
* error (SyntaxError, ReferenceError, uncaught rejection, etc.).
|
||||
*
|
||||
* Import `test` from this module instead of `@playwright/test` so every spec
|
||||
* gets both behaviours automatically without per-file boilerplate.
|
||||
*/
|
||||
export const test = base.extend<object, WorkerFixtures>({
|
||||
stack: [
|
||||
async ({}, use) => {
|
||||
if (!USE_CONTAINERS) {
|
||||
await use(null);
|
||||
return;
|
||||
}
|
||||
const stack = await startStack();
|
||||
try {
|
||||
await seedAdmin(stack.baseURL);
|
||||
await use(stack);
|
||||
} finally {
|
||||
await stack.stop();
|
||||
}
|
||||
},
|
||||
// Worker-scoped: booting Postgres + the app container (image build on
|
||||
// first run, migrations on boot) can take well over the default 30s
|
||||
// fixture timeout. Match the 180s container startup budget in startStack.
|
||||
{ scope: 'worker', timeout: 200_000 },
|
||||
],
|
||||
|
||||
// Point relative `page.goto('/')` at the per-worker stack when present;
|
||||
// otherwise fall back to the baseURL configured in the project (the
|
||||
// legacy webServer at :8087).
|
||||
baseURL: async ({ stack }, use, testInfo) => {
|
||||
await use(stack ? stack.baseURL : testInfo.project.use.baseURL);
|
||||
},
|
||||
|
||||
page: async ({ page }, use) => {
|
||||
const jsErrors: Error[] = [];
|
||||
page.on('pageerror', (err) => jsErrors.push(err));
|
||||
await use(page);
|
||||
if (jsErrors.length > 0) {
|
||||
throw new Error(
|
||||
`${jsErrors.length} unhandled JS error(s) on page:\n` +
|
||||
jsErrors.map((e) => ` • ${e.message}`).join('\n')
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const TEST_ADMIN = {
|
||||
username: 'admin',
|
||||
email: 'testadmin@example.com',
|
||||
password: 'TestPassword1!',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the first-admin account via the public `POST /api/setup` route.
|
||||
* Idempotent: a 409 (admin already exists) is treated as success so the
|
||||
* call is safe to retry and to run once per worker.
|
||||
*
|
||||
* Shared by `global-setup.ts` (legacy flow, single server) and the
|
||||
* worker-scoped `stack` fixture (container flow, one server per worker).
|
||||
*/
|
||||
export async function seedAdmin(baseURL: string, admin = TEST_ADMIN): Promise<void> {
|
||||
const res = await fetch(`${baseURL}/api/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
password: admin.password,
|
||||
}),
|
||||
});
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`Admin setup failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the page's browser context, ready for subsequent
|
||||
* `page.goto()` calls to load already-signed-in.
|
||||
*
|
||||
* Uses the SPA's real login flow (`page.goto('/login')` → fill form
|
||||
* → submit) rather than a bare `POST /api/auth/login`, so this works
|
||||
* correctly under both auth modes the test env supports:
|
||||
*
|
||||
* * `OXICLOUD_AUTH_OPAQUE_MODE=off` — SPA does legacy login,
|
||||
* server accepts.
|
||||
* * `OXICLOUD_AUTH_OPAQUE_MODE=migrate` — first login legacy-
|
||||
* succeeds + silently mints an OPAQUE envelope (Phase 2 hook);
|
||||
* every subsequent login the SPA detects the envelope via
|
||||
* `/api/auth/opaque/login/lookup` and does the full KE1/KE3
|
||||
* OPAQUE handshake. Legacy `POST /api/auth/login` would 403
|
||||
* with `opaque_migrated_use_opaque` (Phase 4) from the second
|
||||
* login on — that's what the old bare-POST apiLogin used to
|
||||
* hit as soon as OPAQUE went from `off` to `migrate`.
|
||||
* * `OXICLOUD_DPOP_MODE=required` — the SPA computes and sends
|
||||
* `dpop_jkt` in the login body; the session is created bound.
|
||||
* A bare-POST wouldn't include it, so subsequent requests
|
||||
* wouldn't get DPoP-signed. Going through the SPA keeps the
|
||||
* end-to-end flow honest.
|
||||
*
|
||||
* Overhead vs the old direct POST: ~200-500 ms per test to load
|
||||
* `/login`, submit, and wait for the post-login redirect. Runs
|
||||
* once per test (from `beforeEach`), so the total suite tax is
|
||||
* modest and the coverage payoff is real.
|
||||
*/
|
||||
export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
|
||||
// Idempotence check — many specs' beforeEach + test body both call
|
||||
// apiLogin; the old bare-POST version was a no-op on a live
|
||||
// session, and callers depend on that. Under UI-driven login,
|
||||
// navigating to /login when already authenticated triggers the
|
||||
// SPA's layout guard to redirect away → the login form never
|
||||
// renders → the fill() below times out. Probe /api/auth/me FIRST:
|
||||
// 2xx means we're already signed in as SOMEONE. If that's the
|
||||
// right admin, no-op; otherwise fall through to a fresh login.
|
||||
const probe = await page.request.get('/api/auth/me').catch(() => null);
|
||||
if (probe?.ok()) {
|
||||
const body = (await probe.json().catch(() => ({}))) as { username?: string };
|
||||
if (body.username === admin.username) return;
|
||||
}
|
||||
|
||||
await page.goto('/login');
|
||||
// Wait for the SPA's boot probes (`getOidcProviders` +
|
||||
// `getAuthStatus` in `login/+page.svelte::onMount`) to complete
|
||||
// BEFORE touching the form. Otherwise the boot `$effect` fires
|
||||
// MID-FILL — when `booting` flips from true to false, the
|
||||
// auto-focus effect steals focus back to the identifier input,
|
||||
// and any remaining characters of the password-fill land in
|
||||
// the username field. Symptom: username="adminTestPassword1!",
|
||||
// password="", submit-button shows "Send sign-in link" → SPA
|
||||
// fires magic-link/send with the concatenated identifier and
|
||||
// login never completes.
|
||||
//
|
||||
// `networkidle` waits for the network to have no more than 0
|
||||
// requests in flight for 500 ms. By that point providers
|
||||
// + status have landed and `booting = false` has already
|
||||
// stabilised → the auto-focus effect fired ONCE (harmlessly,
|
||||
// before we touch the form), never again during our fills.
|
||||
await page.waitForLoadState('networkidle');
|
||||
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();
|
||||
// Post-login the SPA's `goto(redirectTarget)` sends the user
|
||||
// to `/files` (default) or a `?redirect=` target — OR to
|
||||
// `/profile?forcePasswordChange=1` when the backend has stamped
|
||||
// `force_password_change_at_next_login=true` on this account
|
||||
// (usually because a prior admin-reset test flipped it). Match
|
||||
// any post-login destination that ISN'T `/login` itself. The
|
||||
// 15s ceiling covers the OPAQUE-post-migration path: WASM load
|
||||
// + KE1 + KE3 + Argon2id.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
|
||||
timeout: 15_000,
|
||||
waitUntil: 'commit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `x-csrf-token` header for a cookie-authenticated, state-changing
|
||||
* request. The server uses a double-submit cookie: login sets a non-HttpOnly
|
||||
* `oxicloud_csrf` cookie, and POST/PUT/DELETE must echo its value in the
|
||||
* header (see `csrf_middleware`). Returns `{}` if no cookie is present (e.g.
|
||||
* not logged in), letting the caller's request fail with the real 4xx.
|
||||
*/
|
||||
async function csrfHeaders(page: Page): Promise<Record<string, string>> {
|
||||
const cookies = await page.context().cookies();
|
||||
const token = cookies.find((c) => c.name === 'oxicloud_csrf')?.value;
|
||||
return token ? { 'x-csrf-token': token } : {};
|
||||
}
|
||||
|
||||
/** A folder as returned by the API (subset we use). */
|
||||
export type ApiFolder = { id: string; parent_id: string | null };
|
||||
|
||||
/**
|
||||
* Create a folder via the API and return it. `parentId` omitted ⇒ the folder
|
||||
* is created in the caller's home (root) folder, and the returned `parent_id`
|
||||
* is that home folder's id (handy as the target for "root" file uploads, which
|
||||
* require an explicit folder id). Requires the page to already be
|
||||
* authenticated (see `apiLogin`).
|
||||
*/
|
||||
export async function apiCreateFolder(
|
||||
page: Page,
|
||||
name: string,
|
||||
parentId?: string,
|
||||
): Promise<ApiFolder> {
|
||||
const res = await page.request.post('/api/folders', {
|
||||
headers: await csrfHeaders(page),
|
||||
data: parentId ? { name, parent_id: parentId } : { name },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiCreateFolder(${name}) failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()) as ApiFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a regular user via the admin API. Requires the page to be authenticated
|
||||
* as an admin (see `apiLogin`). Returns the created username. Handy for tests
|
||||
* that need a second account (sharing, group membership, recipient search).
|
||||
*/
|
||||
export async function apiAdminCreateUser(page: Page, username: string): Promise<string> {
|
||||
const res = await page.request.post('/api/admin/users', {
|
||||
headers: await csrfHeaders(page),
|
||||
data: {
|
||||
username,
|
||||
password: 'TestPassword1!',
|
||||
email: `${username}@example.test`,
|
||||
role: 'user',
|
||||
quota_bytes: 1073741824,
|
||||
},
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiAdminCreateUser(${username}) failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a group via the API (requires an authenticated admin/manager). Returns
|
||||
* the group name. Useful for sharing-with-group and group-membership tests.
|
||||
*/
|
||||
export async function apiCreateGroup(page: Page, name: string): Promise<string> {
|
||||
const res = await page.request.post('/api/groups', {
|
||||
headers: await csrfHeaders(page),
|
||||
data: { name, description: null },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiCreateGroup(${name}) failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Move a folder to trash via the API (DELETE /api/folders/{id}). */
|
||||
export async function apiTrashFolder(page: Page, folderId: string): Promise<void> {
|
||||
const res = await page.request.delete(`/api/folders/${folderId}`, {
|
||||
headers: await csrfHeaders(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiTrashFolder(${folderId}) failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an access in the user's "recent" list (POST /api/recent/{type}/{id})
|
||||
* so the /recent route has deterministic content. Best-effort: a non-2xx is
|
||||
* tolerated so callers don't fail on a recents quirk.
|
||||
*/
|
||||
export async function apiRecordRecent(
|
||||
page: Page,
|
||||
itemType: 'file' | 'folder',
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await page.request
|
||||
.post(`/api/recent/${itemType}/${id}`, { headers: await csrfHeaders(page) })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Empty the trash via the API (DELETE /api/trash/empty) for a clean slate. */
|
||||
export async function apiEmptyTrash(page: Page): Promise<void> {
|
||||
const res = await page.request.delete('/api/trash/empty', { headers: await csrfHeaders(page) });
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiEmptyTrash failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the caller's `ui_preferences.hide_dotfiles` server-side. Used by the
|
||||
* dotfile-filter e2e spec to establish a known state at test start and to
|
||||
* clean up at teardown so sibling tests aren't polluted by a leftover
|
||||
* "hidden" mode (the preference is persistent across sessions because it's
|
||||
* stored on `auth.users.ui_preferences`, not in localStorage).
|
||||
*
|
||||
* PATCHes only `hide_dotfiles`; siblings in the bag (view_mode, future
|
||||
* keys) survive the shallow-merge on the server side.
|
||||
*/
|
||||
export async function apiSetHideDotfiles(page: Page, hide: boolean): Promise<void> {
|
||||
const res = await page.request.patch('/api/auth/me/profile', {
|
||||
headers: await csrfHeaders(page),
|
||||
data: { ui_preferences: { hide_dotfiles: hide } },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiSetHideDotfiles(${hide}) failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A file to seed: its name, MIME type, and raw bytes. */
|
||||
export type SeedFile = { name: string; mimeType: string; body: Buffer };
|
||||
|
||||
/**
|
||||
* Upload one file via the API into `folderId`. The target folder is
|
||||
* **required**: the server resolves the file's owner from its parent folder
|
||||
* and rejects an upload with no `folder_id` ("folder_id is required to
|
||||
* determine file owner"). For a "root" file, pass the home folder's id — the
|
||||
* `parent_id` returned by `apiCreateFolder(name)`.
|
||||
*
|
||||
* The `folder_id` field is sent before `file` because the upload handler
|
||||
* parses the multipart stream in order and permission-checks the target folder
|
||||
* before spooling the body.
|
||||
*/
|
||||
export async function apiUploadFile(
|
||||
page: Page,
|
||||
file: SeedFile,
|
||||
folderId: string,
|
||||
): Promise<void> {
|
||||
const filePart = { name: file.name, mimeType: file.mimeType, buffer: file.body };
|
||||
const res = await page.request.post('/api/files/upload', {
|
||||
headers: await csrfHeaders(page),
|
||||
multipart: { folder_id: folderId, file: filePart },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiUploadFile(${file.name}) failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small library of files spanning common types (text, markdown, JSON, CSV,
|
||||
* PNG image, PDF), so a recording starts from a browser that exercises the
|
||||
* different icons / previews / row renderers. Bytes are tiny but valid.
|
||||
*/
|
||||
export const SAMPLE_FILES = {
|
||||
text: (): SeedFile => ({
|
||||
name: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
body: Buffer.from('Hello from the codegen seed.\nLine two.\n'),
|
||||
}),
|
||||
markdown: (): SeedFile => ({
|
||||
name: 'README.md',
|
||||
mimeType: 'text/markdown',
|
||||
body: Buffer.from('# Seeded\n\nA **markdown** file for the file browser.\n'),
|
||||
}),
|
||||
json: (): SeedFile => ({
|
||||
name: 'config.json',
|
||||
mimeType: 'application/json',
|
||||
body: Buffer.from(JSON.stringify({ seeded: true, items: [1, 2, 3] }, null, 2)),
|
||||
}),
|
||||
csv: (): SeedFile => ({
|
||||
name: 'data.csv',
|
||||
mimeType: 'text/csv',
|
||||
body: Buffer.from('id,name,size\n1,alpha,10\n2,beta,20\n'),
|
||||
}),
|
||||
png: (): SeedFile => ({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
// 1×1 transparent PNG.
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
}),
|
||||
pdf: (): SeedFile => ({
|
||||
name: 'sample.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
body: Buffer.from(
|
||||
'%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
|
||||
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
|
||||
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]>>endobj\n' +
|
||||
'trailer<</Root 1 0 R>>\n%%EOF\n',
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a representative tree of folders and files of different types for the
|
||||
* authenticated user, so a codegen recording (or a test) starts from a
|
||||
* populated file browser. Idempotent enough for one run per worker; re-running
|
||||
* creates duplicate names (the backend allows them). Requires `apiLogin` first.
|
||||
*
|
||||
* Layout created in the user's home folder:
|
||||
*
|
||||
* config.json (home/root)
|
||||
* pixel.png (home/root)
|
||||
* Documents/ README.md, notes.txt
|
||||
* Documents/Reports/ data.csv, sample.pdf
|
||||
* Images/ pixel.png
|
||||
*
|
||||
* Returns the created folder ids (plus the resolved `home` id) so callers can
|
||||
* deep-link or assert.
|
||||
*/
|
||||
export async function seedFilesAndFolders(
|
||||
page: Page,
|
||||
): Promise<{ home: string; documents: string; reports: string; images: string }> {
|
||||
const documents = await apiCreateFolder(page, 'Documents');
|
||||
const reports = await apiCreateFolder(page, 'Reports', documents.id);
|
||||
const images = await apiCreateFolder(page, 'Images');
|
||||
|
||||
// Created-at-root folders carry the home folder id as their parent — use it
|
||||
// as the target for the "root" files (uploads require an explicit folder).
|
||||
const home = documents.parent_id;
|
||||
if (!home) {
|
||||
throw new Error('seedFilesAndFolders: could not resolve home folder id from a root folder');
|
||||
}
|
||||
|
||||
await apiUploadFile(page, SAMPLE_FILES.json(), home);
|
||||
await apiUploadFile(page, SAMPLE_FILES.png(), home);
|
||||
await apiUploadFile(page, SAMPLE_FILES.markdown(), documents.id);
|
||||
await apiUploadFile(page, SAMPLE_FILES.text(), documents.id);
|
||||
await apiUploadFile(page, SAMPLE_FILES.csv(), reports.id);
|
||||
await apiUploadFile(page, SAMPLE_FILES.pdf(), reports.id);
|
||||
await apiUploadFile(page, SAMPLE_FILES.png(), images.id);
|
||||
|
||||
return { home, documents: documents.id, reports: reports.id, images: images.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the test admin and wait until the main app is fully initialized.
|
||||
*
|
||||
* We wait for two things after the login redirect:
|
||||
* 1. `#sidebar` — confirms the main HTML has loaded.
|
||||
* 2. `#user-avatar-btn .user-vignette` — confirms that `setupUserMenu()` has
|
||||
* run and mounted the avatar vignette. This is the earliest reliable
|
||||
* signal that the click-handler on the avatar button is attached, so any
|
||||
* subsequent test that opens the user menu will not race against JS startup.
|
||||
*
|
||||
* Without (2), CI (Ubuntu + Xvfb) occasionally clicks the button before the
|
||||
* event listener is registered because the JS runtime is slower than on macOS.
|
||||
*/
|
||||
export async function loginAsAdmin(page: Page) {
|
||||
await goToLoginPage(page);
|
||||
await page.locator('#login-username').fill(TEST_ADMIN.username);
|
||||
await page.locator('#login-password').fill(TEST_ADMIN.password);
|
||||
await page.locator('#login-submit').click();
|
||||
await expect(page.locator('#sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
// Wait for the JS app to initialise: avatar vignette present ⟹ click handler attached.
|
||||
await expect(page.locator('#user-avatar-btn .user-vignette')).toBeAttached({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to `/` and land on the login panel, handling the language selector
|
||||
* if it appears (fresh localStorage). The admin account is guaranteed to exist
|
||||
* because globalSetup created it before any test ran.
|
||||
*/
|
||||
export async function goToLoginPage(page: Page) {
|
||||
await page.goto('/');
|
||||
|
||||
// Both panels start with .hidden — wait for JS to reveal one.
|
||||
// Use expect() (5 s default) rather than waitForSelector() (30 s) so a JS
|
||||
// crash fails fast instead of hanging for the full test timeout.
|
||||
await expect(
|
||||
page.locator('#language-panel:not(.hidden), #login-panel:not(.hidden)').first()
|
||||
).toBeAttached();
|
||||
|
||||
if (await page.locator('#language-panel').isVisible()) {
|
||||
await page.locator('#language-continue').click();
|
||||
}
|
||||
|
||||
await expect(page.locator('#login-panel')).toBeVisible();
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Interactive Playwright codegen driver (invoked by `just front-codegen`).
|
||||
#
|
||||
# Pick a starting-point recorder from scenarios/codegen/, record against a
|
||||
# throwaway container stack, then turn the recording into a real spec:
|
||||
# record → name (re-prompts until free) → $EDITOR paste → assemble → run →
|
||||
# reopen in --debug on failure.
|
||||
set -euo pipefail
|
||||
|
||||
# Run from tests/e2e regardless of how we were invoked.
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# ── Pick a recorder (menu of scenarios/codegen/*.spec.ts) ─────────────────────
|
||||
shopt -s nullglob
|
||||
files=(scenarios/codegen/*.spec.ts)
|
||||
[[ ${#files[@]} -gt 0 ]] || { echo "no recorder files in scenarios/codegen/" >&2; exit 1; }
|
||||
names=()
|
||||
for f in "${files[@]}"; do names+=("$(basename "$f" .spec.ts)"); done
|
||||
echo "Pick a codegen starting point:"
|
||||
PS3="› "
|
||||
select n in "${names[@]}"; do [[ -n "${n:-}" ]] && break; echo " invalid choice — enter a number"; done
|
||||
echo "→ recorder: $n"
|
||||
|
||||
# ── Environment ───────────────────────────────────────────────────────────────
|
||||
# NixOS (and distros where Playwright's bundled chromium can't run) need a
|
||||
# system chromium. Respect an explicit PW_CHROMIUM_PATH, else auto-detect.
|
||||
chromium="${PW_CHROMIUM_PATH:-$(command -v chromium 2>/dev/null || true)}"
|
||||
if [[ -n "$chromium" ]]; then export PW_CHROMIUM_PATH="$chromium"; echo "→ chromium: $chromium"; fi
|
||||
# Build the app image from the CURRENT source for codegen, so recordings reflect
|
||||
# the latest frontend (and its data-testid hooks) instead of a stale image.
|
||||
#
|
||||
# We build it HERE with the buildx CLI rather than letting the Testcontainers
|
||||
# fixture build it. The fixture's JS-driven BuildKit build does NOT reuse the
|
||||
# local cargo cache mounts, so it cold-compiles the whole binary every run
|
||||
# (>5 min) and blows past the 200 s stack-setup timeout. The CLI build uses the
|
||||
# `builder-cache` stage and the shared cache mounts, so it recompiles only what
|
||||
# changed (seconds on a warm cache); we then hand the tag to the fixture via
|
||||
# $OXICLOUD_IMAGE, which makes it skip its own build entirely.
|
||||
#
|
||||
# Escape hatch: export OXICLOUD_IMAGE yourself to force-reuse a prebuilt image
|
||||
# (it must be built with --build-arg VITE_E2E=1, and may be stale vs local edits).
|
||||
if [[ -n "${OXICLOUD_IMAGE:-}" ]]; then
|
||||
echo "→ OXICLOUD_IMAGE=$OXICLOUD_IMAGE (env override) — reusing it; needs VITE_E2E=1 and may be stale."
|
||||
else
|
||||
REPO_ROOT="$(cd ../.. && pwd)"
|
||||
OXICLOUD_IMAGE="oxicloud-e2e:latest"
|
||||
echo "→ building $OXICLOUD_IMAGE from current source (buildx, incremental — only changed crates/assets recompile)…"
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg BUILDER=builder-cache \
|
||||
--build-arg BIN_DIR=/app/bin \
|
||||
--build-arg VITE_E2E=1 \
|
||||
--tag "$OXICLOUD_IMAGE" \
|
||||
"$REPO_ROOT"
|
||||
export OXICLOUD_IMAGE
|
||||
echo "→ OXICLOUD_IMAGE=$OXICLOUD_IMAGE (prebuilt; fixture will reuse it)"
|
||||
fi
|
||||
export OXICLOUD_E2E_CONTAINERS=1
|
||||
|
||||
# ── Record ────────────────────────────────────────────────────────────────────
|
||||
npx playwright test -c playwright.codegen.config.ts "scenarios/codegen/$n.spec.ts" --headed --workers=1
|
||||
|
||||
# ── Save the recording as a real spec ─────────────────────────────────────────
|
||||
# Keeps the template's setup (apiLogin/goto), drops page.pause(), and splices in
|
||||
# the steps you paste (handles a full codegen file or bare action lines).
|
||||
echo
|
||||
# Prompt for a spec name until it's valid and not already taken (blank = skip).
|
||||
spec=""
|
||||
while :; do
|
||||
read -rp "Save recording to a spec? Enter a name (blank to skip): " out || true
|
||||
[[ -z "${out:-}" ]] && break
|
||||
spec="$(node scripts/finish-codegen.mjs --resolve "$out")" && break || true
|
||||
done
|
||||
[[ -n "$spec" ]] || exit 0
|
||||
|
||||
# Collect the recording in $VISUAL/$EDITOR (paste, then save & close). For GUI
|
||||
# editors set a blocking flag, e.g. EDITOR="code --wait". Falls back to nano/vi,
|
||||
# then to a Ctrl-D paste if no editor is available.
|
||||
editor="${VISUAL:-${EDITOR:-}}"
|
||||
[[ -z "$editor" ]] && editor="$(command -v nano 2>/dev/null || command -v vi 2>/dev/null || true)"
|
||||
tmp="$(mktemp --suffix=.recording.ts)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if [[ -n "$editor" ]]; then
|
||||
echo "Opening $editor — paste the recorded output, then save & close."
|
||||
$editor "$tmp"
|
||||
recording="$(cat "$tmp")"
|
||||
else
|
||||
echo "No \$VISUAL/\$EDITOR set. Paste the recorded output, then press Ctrl-D:"
|
||||
recording="$(cat)"
|
||||
fi
|
||||
|
||||
if [[ -z "${recording//[[:space:]]/}" ]]; then
|
||||
echo "(empty — nothing saved)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s' "$recording" | node scripts/finish-codegen.mjs "scenarios/codegen/$n.spec.ts" "$out" >/dev/null
|
||||
|
||||
# ── Run the new spec; reopen in --debug on failure ────────────────────────────
|
||||
echo "→ running $spec"
|
||||
if npx playwright test -c playwright.containers.config.ts "$spec" --workers=1; then
|
||||
echo "✓ $spec passed"
|
||||
else
|
||||
echo "✗ $spec failed — reopening in debug mode…"
|
||||
npx playwright test -c playwright.containers.config.ts "$spec" --workers=1 --debug
|
||||
fi
|
||||
@@ -1,120 +0,0 @@
|
||||
// Assemble a real spec from a codegen recording.
|
||||
//
|
||||
// Usage: node scripts/finish-codegen.mjs <templatePath> <outName> (recording on stdin)
|
||||
//
|
||||
// Takes the recorder template the user started from (scenarios/codegen/<x>.spec.ts),
|
||||
// keeps its SETUP (everything before page.pause(), minus test.setTimeout), and
|
||||
// splices in the recorded steps read from stdin — writing scenarios/<outName>.spec.ts.
|
||||
//
|
||||
// The pasted recording may be either bare action lines, e.g.
|
||||
// await page.getByText('…').click();
|
||||
// or a whole codegen file, e.g.
|
||||
// import { test, expect } from '@playwright/test';
|
||||
// test('test', async ({ page }) => { await page.…; });
|
||||
// In the latter case the import + test(...) wrapper are stripped, leaving the body.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
/** Sanitize a name to scenarios/<base>.spec.ts; exit non-zero if invalid/taken. */
|
||||
function resolveOutPath(rawName) {
|
||||
const base = (rawName ?? '')
|
||||
.trim()
|
||||
.replace(/\.spec\.ts$/, '')
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (!base) {
|
||||
console.error('finish-codegen: empty/invalid output name');
|
||||
process.exit(1);
|
||||
}
|
||||
const outPath = `scenarios/${base}.spec.ts`;
|
||||
if (existsSync(outPath)) {
|
||||
console.error(`finish-codegen: ${outPath} already exists — pick another name`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { base, outPath };
|
||||
}
|
||||
|
||||
// `--resolve <name>`: validate a name + print its spec path (no stdin, no write).
|
||||
// The just recipe loops this to re-prompt until a free, valid name is given.
|
||||
if (argv[0] === '--resolve') {
|
||||
console.log(resolveOutPath(argv[1]).outPath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const [templatePath, rawOutName] = argv;
|
||||
if (!templatePath || !rawOutName) {
|
||||
console.error('usage: finish-codegen.mjs <templatePath> <outName> (recording on stdin)');
|
||||
console.error(' or: finish-codegen.mjs --resolve <outName>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const recording = readFileSync(0, 'utf-8'); // stdin
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const { base, outPath } = resolveOutPath(rawOutName);
|
||||
|
||||
/** Body between the first `=> {` and the final `});` of a test file/snippet. */
|
||||
function callbackBody(src) {
|
||||
const m = src.match(/=>\s*\{([\s\S]*)\}\s*\)\s*;?\s*$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Re-indent non-empty lines to a common 2-space base. */
|
||||
function reindent(lines, spaces = 2) {
|
||||
const body = lines.filter((l) => l.trim());
|
||||
const min = body.length ? Math.min(...body.map((l) => l.match(/^\s*/)[0].length)) : 0;
|
||||
const pad = ' '.repeat(spaces);
|
||||
return lines.map((l) => (l.trim() ? pad + l.slice(min) : ''));
|
||||
}
|
||||
|
||||
// ── 1. helpers import from the template (fix ../helpers → ./helpers) ──────────
|
||||
const helperImport =
|
||||
(template.match(/^import .*from ['"]\.\.\/helpers['"];?$/m) || [])[0]?.replace(
|
||||
/\.\.\/helpers/,
|
||||
'./helpers',
|
||||
) ?? "import { test } from './helpers';";
|
||||
|
||||
// ── 2. setup lines = template body before page.pause(), minus setTimeout ──────
|
||||
const tBody = callbackBody(template);
|
||||
const setupLines = reindent(
|
||||
(tBody ?? '')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim() && !/page\.pause\(/.test(l) && !/test\.setTimeout\(/.test(l)),
|
||||
);
|
||||
|
||||
// ── 3. recorded steps from stdin (strip wrapper if a whole file was pasted) ───
|
||||
let recBody = recording;
|
||||
if (/^\s*(import\b|test\s*\()/.test(recording)) {
|
||||
recBody = callbackBody(recording) ?? recording;
|
||||
}
|
||||
let actionLines = recBody.split('\n').filter((l) => !/^\s*import\s/.test(l));
|
||||
while (actionLines.length && !actionLines[0].trim()) actionLines.shift();
|
||||
while (actionLines.length && !actionLines[actionLines.length - 1].trim()) actionLines.pop();
|
||||
actionLines = reindent(actionLines);
|
||||
if (!actionLines.length) {
|
||||
console.error('finish-codegen: no recorded steps found in the pasted input');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── 4. compose ───────────────────────────────────────────────────────────────
|
||||
const imports = [helperImport];
|
||||
if (/\bexpect\(/.test(actionLines.join('\n'))) {
|
||||
imports.push("import { expect } from '@playwright/test';");
|
||||
}
|
||||
|
||||
const out = [
|
||||
...imports,
|
||||
'',
|
||||
`test('${base.replace(/'/g, "\\'")}', async ({ page }) => {`,
|
||||
...setupLines,
|
||||
'',
|
||||
' // recorded steps',
|
||||
...actionLines,
|
||||
'});',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
writeFileSync(outPath, out);
|
||||
console.error(`✓ wrote ${outPath}`);
|
||||
console.log(outPath); // stdout = machine-readable path; the recipe runs it
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/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"
|
||||
|
||||
# Mirror everything (these markers + the server's own stdout/stderr) to a log
|
||||
# file as well as the console. Playwright captures the webServer's stdout, but
|
||||
# in CI that capture isn't always surfaced in the job log — the file is, via an
|
||||
# `if: always()` "print server startup log" step in ci.yml. The final
|
||||
# `exec "$@"` below inherits these redirected fds, so the server's output is
|
||||
# tee'd too, while the process still replaces this shell (Playwright tracks the
|
||||
# PID for teardown).
|
||||
SERVER_LOG="$REPO_ROOT/tests/e2e/server-startup.log"
|
||||
exec > >(tee "$SERVER_LOG") 2>&1
|
||||
|
||||
mark() { echo "[start-server $(date -u +%H:%M:%S)] $*"; }
|
||||
|
||||
mark "repo_root=$REPO_ROOT"
|
||||
mark "server binary args: $*"
|
||||
if [[ -n "${1:-}" && "$1" != "cargo" ]]; then
|
||||
ls -la "$1" 2>&1 || mark "WARNING: server binary '$1' not found"
|
||||
fi
|
||||
mark "DATABASE_URL=${DATABASE_URL:-<unset>} OXICLOUD_SERVER_PORT=${OXICLOUD_SERVER_PORT:-<unset>}"
|
||||
|
||||
# ensure storage is empty before starting
|
||||
mark "wiping $OXICLOUD_STORAGE_PATH to ensure clean startup"
|
||||
rm -rf "$OXICLOUD_STORAGE_PATH"
|
||||
mkdir -p "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
# Spawn database
|
||||
mark "spawning test database…"
|
||||
bash "$REPO_ROOT/tests/common/spawn-db.sh"
|
||||
mark "database ready; starting server…"
|
||||
|
||||
# Point `--config` at an INTENTIONALLY EMPTY file. Two reasons:
|
||||
#
|
||||
# 1. Blocks the fallback `dotenvy::dotenv()` probe of `$CWD/.env`
|
||||
# in main.rs — otherwise a developer's local `.env`
|
||||
# (typical: `OXICLOUD_METRICS_LISTEN=127.0.0.1:9090`) leaks
|
||||
# into the test server via CWD auto-load and clashes with
|
||||
# anything the dev is already running.
|
||||
#
|
||||
# 2. An empty file has nothing to override — so every var
|
||||
# Playwright loaded into `webServer.env` from server.env AND
|
||||
# every per-suite override on top (SERVER_PORT, STORAGE_PATH,
|
||||
# RUST_LOG, OPAQUE_MODE, DPOP_MODE, …) reaches the server
|
||||
# intact. We can't use `--config server.env` here because
|
||||
# `dotenvy::from_filename_override` would clobber Playwright's
|
||||
# per-suite overrides for the keys it shares with the file.
|
||||
#
|
||||
# See tests/common/empty.env for the header explaining this.
|
||||
CONFIG_PATH="$REPO_ROOT/tests/common/empty.env"
|
||||
|
||||
# `cargo run` needs `--` to separate cargo's own flags from the
|
||||
# binary's — a direct-binary invocation takes them raw. First arg
|
||||
# tells us which we're in.
|
||||
if [[ "${1:-}" == "cargo" ]]; then
|
||||
exec "$@" -- --config "$CONFIG_PATH"
|
||||
else
|
||||
exec "$@" --config "$CONFIG_PATH"
|
||||
fi
|
||||