test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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,13 +1,60 @@
|
||||
import { test as base, Page, expect } from '@playwright/test';
|
||||
import { startStack, Stack } from '../fixtures/oxicloud-stack';
|
||||
|
||||
/**
|
||||
* Extended `test` fixture that fails on any unhandled browser-side JavaScript
|
||||
* error (SyntaxError, ReferenceError, uncaught promise rejections, etc.).
|
||||
* 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 the listener automatically without per-file boilerplate.
|
||||
* gets both behaviours automatically without per-file boilerplate.
|
||||
*/
|
||||
export const test = base.extend<object>({
|
||||
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));
|
||||
@@ -27,6 +74,274 @@ export const TEST_ADMIN = {
|
||||
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 via the API, so a subsequent
|
||||
* `page.goto()` loads already signed in — no UI clicks. Selector-independent,
|
||||
* which keeps it robust while the SPA login markup is still in flux.
|
||||
*
|
||||
* `POST /api/auth/login` is CSRF-exempt and sets the auth cookies on the
|
||||
* context's (shared) cookie jar, so `page.request` here authenticates the
|
||||
* page too. Use this at the top of any spec that needs an authenticated app
|
||||
* (and in the codegen recorder, so you record post-login flows).
|
||||
*/
|
||||
export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
|
||||
const res = await page.request.post('/api/auth/login', {
|
||||
data: { username: admin.username, password: admin.password },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`apiLogin failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user