mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// The /admin area, which nothing covered before.
|
||||
//
|
||||
// Admin is not a database column. lib/auth.ts:144 derives `token.isAdmin` on
|
||||
// every request by looking the signed-in address up in the ADMIN_EMAILS
|
||||
// environment variable of the *app under test*. That has two consequences for
|
||||
// this file:
|
||||
//
|
||||
// 1. The privileged account has to use a fixed address rather than the
|
||||
// per-test unique one every other spec gets, hence ADMIN_EMAIL below.
|
||||
// Because that address is unique in the database, the two tests that use it
|
||||
// must not run at the same time, hence the serial describe.
|
||||
// 2. That variable therefore has to be set for the app under test, and
|
||||
// playwright.config.ts sets it in APP_ENV to exactly ADMIN_EMAIL below.
|
||||
// Remove it and the privileged half of this spec has nothing to sign in as,
|
||||
// so the probe below **fails** rather than skipping: this is the only
|
||||
// positive coverage of the admin area in the repo outside
|
||||
// tests/api/auth-matrix.test.ts, and a config change should not be able to
|
||||
// quietly delete it.
|
||||
//
|
||||
// The probe reads /api/auth/session, which is a question about configuration,
|
||||
// not about the authorization being tested: if the /admin guard itself
|
||||
// regressed, isAdmin would still be true and the tests below would fail on
|
||||
// their own assertions.
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import { anonTest, expect, E2E_PASSWORD, signInPage } from './fixtures';
|
||||
import { createUser } from '../factories';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* The address that must appear in the app's ADMIN_EMAILS for the privileged
|
||||
* tests to run. Lower case, because lib/auth.ts lower-cases both sides.
|
||||
*/
|
||||
const ADMIN_EMAIL = '[email protected]';
|
||||
|
||||
const ADMIN_SETUP_HINT =
|
||||
`The app under test does not treat ${ADMIN_EMAIL} as an admin. ` +
|
||||
`APP_ENV in playwright.config.ts must set ADMIN_EMAILS to '${ADMIN_EMAIL}'; ` +
|
||||
`this is a failure rather than a skip because it is the only place the admin ` +
|
||||
`area is exercised from a browser.`;
|
||||
|
||||
/** Whether the app considers the signed-in account an admin. */
|
||||
async function sessionIsAdmin(request: APIRequestContext): Promise<boolean> {
|
||||
const response = await request.get('/api/auth/session');
|
||||
if (!response.ok()) return false;
|
||||
const session = (await response.json()) as { user?: { isAdmin?: boolean } };
|
||||
return session.user?.isAdmin === true;
|
||||
}
|
||||
|
||||
/** Creates the fixed-address admin account and signs `page` in as it. */
|
||||
async function signInAsAdmin(page: Page): Promise<void> {
|
||||
await db.user.deleteMany({ where: { email: ADMIN_EMAIL } });
|
||||
await createUser({ name: 'E2E Admin', email: ADMIN_EMAIL, password: E2E_PASSWORD });
|
||||
await signInPage(page, ADMIN_EMAIL);
|
||||
}
|
||||
|
||||
anonTest.describe('the admin area', () => {
|
||||
// ADMIN_EMAIL is a unique column, so only one test may hold it at a time.
|
||||
anonTest.describe.configure({ mode: 'serial' });
|
||||
|
||||
anonTest.afterEach(async () => {
|
||||
await db.user.deleteMany({ where: { email: ADMIN_EMAIL } });
|
||||
});
|
||||
|
||||
anonTest('an ordinary signed-in user is refused every admin page', async ({ page, seed }) => {
|
||||
const user = await seed.user({ name: 'Not An Admin' });
|
||||
await signInPage(page, user.email ?? '');
|
||||
|
||||
// The session is real: without this the redirects below would prove nothing
|
||||
// more than that /admin is behind a login.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
expect(await sessionIsAdmin(page.context().request)).toEqual(false);
|
||||
|
||||
for (const route of ['/admin', '/admin/users', '/admin/feedback']) {
|
||||
await page.goto(route);
|
||||
// app/admin/layout.tsx sends a non-admin to the marketing root.
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard Overview' })).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Refused, not missing. A route that did not exist would answer 404, and a
|
||||
// 404 would satisfy every assertion above for the wrong reason.
|
||||
const missing = await page.request.get('/definitely-not-a-route', { maxRedirects: 0 });
|
||||
expect(missing.status()).toEqual(404);
|
||||
const admin = await page.request.get('/admin', { maxRedirects: 0 });
|
||||
expect(admin.status()).not.toEqual(404);
|
||||
|
||||
// The write side is guarded independently of the pages.
|
||||
const refresh = await page.request.post('/api/admin/stats/refresh-r2');
|
||||
expect(refresh.status()).toEqual(403);
|
||||
});
|
||||
|
||||
anonTest(
|
||||
'an admin reaches the dashboard and can search the user list',
|
||||
async ({ page, seed }) => {
|
||||
// Seeded before the probe so the search below has something to find.
|
||||
const target = await seed.user({ name: 'Findable Person' });
|
||||
const targetEmail = target.email ?? '';
|
||||
|
||||
await signInAsAdmin(page);
|
||||
expect(await sessionIsAdmin(page.context().request), ADMIN_SETUP_HINT).toBe(true);
|
||||
|
||||
await page.goto('/admin');
|
||||
await expect(page).toHaveURL(/\/admin$/);
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard Overview' })).toBeVisible();
|
||||
|
||||
// The dashboard is not a static shell: it counts rows, and the count has to
|
||||
// be at least the two accounts this test created.
|
||||
const totalUsers = Number(
|
||||
(await page.getByText('Total Users').locator('xpath=../..').innerText())
|
||||
.replace(/[^0-9]/g, '')
|
||||
.trim()
|
||||
);
|
||||
expect(totalUsers).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// --- one action: search the user list -----------------------------------
|
||||
await page.getByRole('link', { name: 'Users' }).first().click();
|
||||
await expect(page).toHaveURL(/\/admin\/users$/);
|
||||
|
||||
// Scoped to the form: the global header carries an icon button whose
|
||||
// accessible name is also "Search".
|
||||
const searchForm = page.locator('form[action="/admin/users"]');
|
||||
const search = searchForm.getByLabel('Search users by name or email');
|
||||
const submit = searchForm.getByRole('button', { name: 'Search' });
|
||||
|
||||
await search.fill(targetEmail);
|
||||
await submit.click();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]q=/);
|
||||
await expect(page.getByText(targetEmail, { exact: true })).toBeVisible();
|
||||
|
||||
// The filter really filters: the admin's own row is in the unfiltered list
|
||||
// and must be absent from this one.
|
||||
await expect(page.getByText(ADMIN_EMAIL, { exact: true })).toHaveCount(0);
|
||||
|
||||
// And a query that matches nobody says so, rather than falling back to
|
||||
// everybody.
|
||||
await search.fill(`no-such-person-${Date.now()}@example.invalid`);
|
||||
await submit.click();
|
||||
await expect(page.getByText('No users match these filters.')).toBeVisible();
|
||||
await expect(page.getByText(targetEmail, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// Multi-select on the project page: deleting several videos at once, and moving
|
||||
// several videos into another project in the same workspace.
|
||||
//
|
||||
// Both flows are behind a selection mode that is only reachable through a card's
|
||||
// overflow menu, so the entry point is exercised here too. The assertions are
|
||||
// deliberately about rows that disappear from one page and appear on another,
|
||||
// not about the toast: a toast can be rendered by a handler that then does
|
||||
// nothing.
|
||||
import { test, expect } from './fixtures';
|
||||
import { createProject, createVideo, createVersion } from '../factories';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The overflow ("more") button on the card for `title`.
|
||||
*
|
||||
* That button is icon-only and has no accessible name, so it cannot be reached
|
||||
* by role and name. The <h3> title can, and the button is a sibling of the link
|
||||
* that wraps it: h3 -> link -> the flex row that holds both. See
|
||||
* components/video-card.tsx.
|
||||
*/
|
||||
function cardMenuFor(page: Page, title: string) {
|
||||
return page
|
||||
.getByRole('heading', { name: title, level: 3 })
|
||||
.locator('xpath=../..')
|
||||
.getByRole('button');
|
||||
}
|
||||
|
||||
/** Puts the page into selection mode through the first card's overflow menu. */
|
||||
async function enterSelectionMode(page: Page, anyTitle: string): Promise<void> {
|
||||
await cardMenuFor(page, anyTitle).click();
|
||||
await page.getByRole('menuitem', { name: 'Select' }).click();
|
||||
await expect(page.getByText('Selection mode')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Three videos in one project, each with an active version.
|
||||
*
|
||||
* The provider is `youtube`, which needs no object storage: nothing here plays
|
||||
* a video, it only lists and deletes them.
|
||||
*/
|
||||
async function seedVideos(projectId: string, titles: string[]): Promise<void> {
|
||||
for (const title of titles) {
|
||||
const video = await createVideo({ projectId, title });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'youtube',
|
||||
providerVideoId: 'dQw4w9WgXcQ',
|
||||
title,
|
||||
duration: 120,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('two of three videos are selected and deleted, and the third survives', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const doomedA = `Bulk Doomed A ${stamp}`;
|
||||
const doomedB = `Bulk Doomed B ${stamp}`;
|
||||
const survivor = `Bulk Survivor ${stamp}`;
|
||||
|
||||
const { project } = await seed.project(seededUser);
|
||||
await seedVideos(project.id, [doomedA, doomedB, survivor]);
|
||||
|
||||
await page.goto(`/projects/${project.id}`);
|
||||
for (const title of [doomedA, doomedB, survivor]) {
|
||||
await expect(page.getByRole('heading', { name: title, level: 3 })).toBeVisible();
|
||||
}
|
||||
|
||||
await enterSelectionMode(page, doomedA);
|
||||
|
||||
// Nothing is selected by the act of entering the mode, so the destructive
|
||||
// button starts disabled. That is the control for the click below.
|
||||
const deleteSelected = page.getByRole('button', { name: 'Delete selected' });
|
||||
await expect(deleteSelected).toBeDisabled();
|
||||
|
||||
await page.getByRole('checkbox', { name: `Select ${doomedA}` }).click();
|
||||
await page.getByRole('checkbox', { name: `Select ${doomedB}` }).click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await expect(deleteSelected).toBeEnabled();
|
||||
|
||||
await deleteSelected.click();
|
||||
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Delete 2 videos?' })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible();
|
||||
|
||||
// Gone from the database, not just from the client-side list that
|
||||
// handleDeleteSelected filters. A reload re-renders from the server.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: survivor, level: 3 })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: doomedA, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: doomedB, level: 3 })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('selected videos are moved into another project in the same workspace', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const moving = `Bulk Moving ${stamp}`;
|
||||
const staying = `Bulk Staying ${stamp}`;
|
||||
|
||||
// Both projects must share a workspace: the move dialog offers only
|
||||
// destinations inside it (app/api/projects/[projectId]/videos/move GET).
|
||||
const { project: source, workspaceId } = await seed.project(seededUser);
|
||||
const destination = await createProject({
|
||||
ownerId: seededUser.id,
|
||||
workspaceId,
|
||||
name: `Bulk Destination ${stamp}`,
|
||||
slug: `e2e-bulk-destination-${stamp}`,
|
||||
});
|
||||
await seedVideos(source.id, [moving, staying]);
|
||||
|
||||
await page.goto(`/projects/${source.id}`);
|
||||
await enterSelectionMode(page, moving);
|
||||
await page.getByRole('checkbox', { name: `Select ${moving}` }).click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Move to project' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Move video to another project' })
|
||||
).toBeVisible();
|
||||
|
||||
// The destination list is fetched when the dialog opens; the combobox does
|
||||
// not exist until it arrives.
|
||||
const destinationSelect = dialog.getByRole('combobox');
|
||||
await expect(destinationSelect).toBeVisible();
|
||||
await destinationSelect.click();
|
||||
await page.getByRole('option', { name: destination.name }).click();
|
||||
await dialog.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expect(page.getByText('1 video moved')).toBeVisible();
|
||||
|
||||
// Left the source...
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: moving, level: 3 })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: staying, level: 3 })).toBeVisible();
|
||||
|
||||
// ...and arrived in the destination. Without both halves this passes for a
|
||||
// delete as readily as for a move.
|
||||
await page.goto(`/projects/${destination.id}`);
|
||||
await expect(page.getByRole('heading', { name: moving, level: 3 })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
// What the user is shown when something fails: an upload that dies mid-flight,
|
||||
// a mutation that comes back 500, and a page whose data never arrives.
|
||||
//
|
||||
// The failures are injected with `page.route`, which is the only honest way in:
|
||||
// the server under test is a production build with no fault injection, and
|
||||
// tearing down MinIO or Postgres mid-run would take the other workers with it.
|
||||
//
|
||||
// Every test here has a positive control. Asserting "an error message appeared"
|
||||
// is worth nothing on its own, because a page that renders an error for every
|
||||
// request would pass it; each test therefore also proves the same flow succeeds
|
||||
// once the interception is removed, or that the data the failed request would
|
||||
// have changed is still exactly as it was.
|
||||
import path from 'node:path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
import { createVideo, createVersion } from '../factories';
|
||||
import { REPO_ROOT } from '../helpers/env';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
const SAMPLE_VIDEO = path.join(REPO_ROOT, 'tests', 'fixtures', 'sample.mp4');
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
/** A video with an active version, cheap enough to make several of. */
|
||||
async function seedVideo(projectId: string, title: string): Promise<void> {
|
||||
const video = await createVideo({ projectId, title });
|
||||
await createVersion({
|
||||
videoParentId: video.id,
|
||||
providerId: 'youtube',
|
||||
providerVideoId: 'dQw4w9WgXcQ',
|
||||
title,
|
||||
duration: 120,
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts the project page into selection mode via the first card's menu. */
|
||||
async function enterSelectionMode(page: Page, anyTitle: string): Promise<void> {
|
||||
await page
|
||||
.getByRole('heading', { name: anyTitle, level: 3 })
|
||||
.locator('xpath=../..')
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Select' }).click();
|
||||
await expect(page.getByText('Selection mode')).toBeVisible();
|
||||
}
|
||||
|
||||
test('an upload that fails at the storage PUT leaves the form up and creates nothing', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const { project } = await seed.project(seededUser);
|
||||
|
||||
// Only the bytes are refused. The presign (`r2-init`) and everything else the
|
||||
// app serves still work, so the failure is exactly the one this test claims:
|
||||
// object storage rejected the upload halfway through.
|
||||
await page.route('http://minio-test:9000/**', async (route) => {
|
||||
if (route.request().method() !== 'PUT') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 500, contentType: 'text/plain', body: 'storage is down' });
|
||||
});
|
||||
|
||||
await page.goto(`/projects/${project.id}/videos/new`);
|
||||
await page.getByRole('tab', { name: 'Direct Upload' }).click();
|
||||
await page.getByLabel('Video Files').setInputFiles(SAMPLE_VIDEO);
|
||||
await page.getByLabel('Title').fill('Doomed Upload');
|
||||
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
|
||||
|
||||
// The status reaches the user rather than being flattened into "something
|
||||
// went wrong": lib/client/r2-video-upload.ts builds the message from the XHR
|
||||
// status and handleSubmit's outer catch renders `error.message` verbatim.
|
||||
// (A single file takes uploadSingleFileWithForm, which is why the message has
|
||||
// no `sample.mp4:` prefix; only the multi-file loop adds one.)
|
||||
await expect(page.getByText('Upload failed with status 500')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Still on the form, so the file list and the title survive for a retry.
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}/videos/new$`));
|
||||
await expect(page.getByLabel('Title')).toHaveValue('Doomed Upload');
|
||||
|
||||
// Nothing half-created. A version row pointing at an object that was never
|
||||
// stored would be worse than the failure itself.
|
||||
await expect.poll(() => db.video.count({ where: { projectId: project.id } })).toEqual(0);
|
||||
|
||||
// Positive control: with storage healthy the very same steps succeed, so the
|
||||
// assertions above are about the injected failure and not about the form
|
||||
// being broken.
|
||||
await page.unroute('http://minio-test:9000/**');
|
||||
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}$`), { timeout: 90_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Doomed Upload', level: 3 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a bulk delete that comes back 500 says so and leaves every video in place', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const first = `Recovery Keep A ${stamp}`;
|
||||
const second = `Recovery Keep B ${stamp}`;
|
||||
|
||||
const { project } = await seed.project(seededUser);
|
||||
await seedVideo(project.id, first);
|
||||
await seedVideo(project.id, second);
|
||||
|
||||
// A non-JSON body on purpose: it drives the client's own fallback message
|
||||
// rather than echoing a string this test supplied.
|
||||
await page.route('**/api/projects/*/videos/bulk-delete', (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'text/plain', body: 'boom' })
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${project.id}`);
|
||||
await enterSelectionMode(page, first);
|
||||
await page.getByRole('checkbox', { name: `Select ${first}` }).click();
|
||||
await page.getByRole('checkbox', { name: `Select ${second}` }).click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete selected' }).click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
||||
|
||||
await expect(page.getByText('Failed to delete selected videos')).toBeVisible();
|
||||
|
||||
// The dialog stays open so the failed action can be retried from where it
|
||||
// was. It has to be dismissed before the cards behind it can be asserted on:
|
||||
// Radix marks everything outside an open alertdialog aria-hidden, so a
|
||||
// heading behind it is not in the accessibility tree at all.
|
||||
await expect(dialog.getByRole('button', { name: 'Delete selected' })).toBeEnabled();
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// The optimistic filter in handleDeleteSelected must not have run: both cards
|
||||
// are still on screen, and both rows are still in the database.
|
||||
await expect(page.getByRole('heading', { name: first, level: 3 })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: second, level: 3 })).toBeVisible();
|
||||
expect(await db.video.count({ where: { projectId: project.id } })).toEqual(2);
|
||||
|
||||
// Positive control: the same click succeeds once the route is released, which
|
||||
// proves the selection and the confirm dialog were driving a real request.
|
||||
await page.unroute('**/api/projects/*/videos/bulk-delete');
|
||||
await page.getByRole('button', { name: 'Delete selected' }).click();
|
||||
await dialog.getByRole('button', { name: 'Delete selected' }).click();
|
||||
|
||||
await expect(page.getByText('2 videos deleted')).toBeVisible();
|
||||
await expect.poll(() => db.video.count({ where: { projectId: project.id } })).toEqual(0);
|
||||
});
|
||||
|
||||
test('a video page whose data request 500s offers a way back instead of an empty player', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const seeded = await seed.version(seededUser, { title: `Recovery Video ${Date.now()}` });
|
||||
const videoRequest = /\/api\/projects\/[^/]+\/videos\/[^/?]+\?includeComments=false/;
|
||||
|
||||
await page.route(videoRequest, (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'text/plain', body: 'database unavailable' })
|
||||
);
|
||||
|
||||
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
|
||||
|
||||
// The status is surfaced rather than swallowed into a generic spinner.
|
||||
await expect(page.getByText(/Failed to load video: 500/)).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Add a comment...')).toHaveCount(0);
|
||||
|
||||
// The escape hatch actually goes somewhere.
|
||||
await page.getByRole('link', { name: 'Back to Project' }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${seeded.project.id}$`));
|
||||
|
||||
// Positive control: without the interception the same URL renders the player
|
||||
// page, so the error state above was caused by the 500 and not by the video
|
||||
// being unreachable for some other reason.
|
||||
await page.unroute(videoRequest);
|
||||
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
|
||||
await expect(page.getByPlaceholder('Add a comment...')).toBeVisible();
|
||||
await expect(page.getByText(/Failed to load video/)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// Player interaction against a real <video> element.
|
||||
//
|
||||
// The pure arithmetic behind these controls lives in
|
||||
// components/video-page/hooks/video-player-utils.ts and is unit tested there.
|
||||
// This spec deliberately covers only what a unit test cannot: that the numbers
|
||||
// the hook computes are actually written to a media element, and that a
|
||||
// keystroke on the document reaches that element. Every assertion below reads
|
||||
// `HTMLVideoElement.currentTime` out of the browser, so a control that renders
|
||||
// but is wired to nothing fails here.
|
||||
//
|
||||
// A real file has to be uploaded first. `<video>` is rendered only for the
|
||||
// `bunny` and `r2` providers (components/video-page/player-core.tsx), and the
|
||||
// seeded `youtube` versions every other spec uses render an iframe instead, so
|
||||
// there is no media element to interrogate. The upload goes through the same
|
||||
// form video-upload.spec.ts drives, against the MinIO service in
|
||||
// docker-compose.test.yml.
|
||||
//
|
||||
// tests/fixtures/sample.mp4 is 2.0 seconds at 10 fps. Those two numbers are
|
||||
// hardcoded in the expectations below on purpose: deriving them from the file
|
||||
// at runtime would let a broken seek agree with a broken measurement.
|
||||
import path from 'node:path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
import { REPO_ROOT } from '../helpers/env';
|
||||
|
||||
const SAMPLE_VIDEO = path.join(REPO_ROOT, 'tests', 'fixtures', 'sample.mp4');
|
||||
const SAMPLE_DURATION_SECONDS = 2;
|
||||
|
||||
// One upload, three network round trips and a MinIO PUT before the first
|
||||
// assertion.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
/** `video.currentTime` as the browser currently reports it. */
|
||||
function currentTime(page: Page): Promise<number> {
|
||||
return page.locator('video').evaluate((el) => (el as HTMLVideoElement).currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads sample.mp4 into a fresh project and opens the video page.
|
||||
*
|
||||
* Returns nothing: everything the assertions need is read off the page.
|
||||
*/
|
||||
async function uploadAndOpen(page: Page, projectId: string, title: string): Promise<void> {
|
||||
await page.goto(`/projects/${projectId}/videos/new`);
|
||||
|
||||
const directUploadTab = page.getByRole('tab', { name: 'Direct Upload' });
|
||||
await expect(directUploadTab).toBeVisible();
|
||||
await directUploadTab.click();
|
||||
|
||||
await page.getByLabel('Video Files').setInputFiles(SAMPLE_VIDEO);
|
||||
await page.getByLabel('Title').fill(title);
|
||||
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}$`), { timeout: 90_000 });
|
||||
await page.getByRole('heading', { name: title, level: 3 }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/videos/[^/]+$`));
|
||||
}
|
||||
|
||||
/** Waits until the media element has read its metadata, then checks it is ours. */
|
||||
async function waitForMetadata(page: Page): Promise<void> {
|
||||
const video = page.locator('video');
|
||||
await expect(video).toBeVisible();
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).readyState), { timeout: 30_000 })
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
|
||||
const duration = await video.evaluate((el) => (el as HTMLVideoElement).duration);
|
||||
expect(duration).toBeGreaterThan(1.9);
|
||||
expect(duration).toBeLessThan(2.2);
|
||||
}
|
||||
|
||||
test('the timeline, the arrow keys and frame mode all move the video element', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const { project } = await seed.project(seededUser);
|
||||
await uploadAndOpen(page, project.id, `Player Video ${Date.now()}`);
|
||||
await waitForMetadata(page);
|
||||
|
||||
expect(await currentTime(page)).toEqual(0);
|
||||
await expect(page.getByText(`0:00 / 0:0${SAMPLE_DURATION_SECONDS}`)).toBeVisible();
|
||||
|
||||
// --- scrubbing ------------------------------------------------------------
|
||||
// The scrub bar carries no role, no label and no id, so it is located by the
|
||||
// class list it is built with in player-core.tsx. Reported rather than worked
|
||||
// around: a keyboard user cannot reach this control at all.
|
||||
const timeline = page.locator('div.h-8.bg-muted.cursor-pointer');
|
||||
await expect(timeline).toBeVisible();
|
||||
const box = await timeline.boundingBox();
|
||||
if (!box) throw new Error('The scrub bar has no layout box.');
|
||||
|
||||
// Three quarters along a two second video is 1.5s. mousedown alone commits
|
||||
// the seek (handleTimelineMouseDown), so a plain click is enough.
|
||||
await timeline.click({ position: { x: box.width * 0.75, y: box.height / 2 } });
|
||||
await expect.poll(() => currentTime(page)).toBeGreaterThan(1.2);
|
||||
await expect(page.getByText(`0:01 / 0:0${SAMPLE_DURATION_SECONDS}`)).toBeVisible();
|
||||
|
||||
// --- keyboard -------------------------------------------------------------
|
||||
// ArrowLeft is 'skip-back' by five seconds, clamped at zero. Starting from
|
||||
// 1.5s means the clamp is the only thing that can produce this value, and it
|
||||
// cannot be the initial state because the scrub above moved off it.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(() => currentTime(page)).toEqual(0);
|
||||
|
||||
// ArrowRight is 'skip-forward' by five, clamped at the duration.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => currentTime(page)).toBeGreaterThan(1.9);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(() => currentTime(page)).toEqual(0);
|
||||
|
||||
// --- frame mode -----------------------------------------------------------
|
||||
// No frame rate has been measured yet (that only happens during playback), so
|
||||
// one step is one second, and the button labels say so. The point of the
|
||||
// assertion is the *difference* from the 10s and 5s jumps above: a step that
|
||||
// lands on 1.0 could not have come from either.
|
||||
await expect(page.getByRole('button', { name: 'Forward 10s' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /^Frame / }).click();
|
||||
const forwardOneStep = page.getByRole('button', { name: 'Forward 1s' });
|
||||
await expect(forwardOneStep).toBeVisible();
|
||||
|
||||
await forwardOneStep.click();
|
||||
await expect.poll(() => currentTime(page)).toBeGreaterThan(0.9);
|
||||
expect(await currentTime(page)).toBeLessThan(1.2);
|
||||
|
||||
await page.getByRole('button', { name: 'Back 1s' }).click();
|
||||
await expect.poll(() => currentTime(page)).toEqual(0);
|
||||
});
|
||||
|
||||
test('the arrow keys are not hijacked while a comment is being typed', async ({
|
||||
page,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const { project } = await seed.project(seededUser);
|
||||
await uploadAndOpen(page, project.id, `Player Typing ${Date.now()}`);
|
||||
await waitForMetadata(page);
|
||||
|
||||
const composer = page.getByPlaceholder('Add a comment...');
|
||||
await composer.fill('cursor keys belong to this box');
|
||||
await composer.click();
|
||||
// Put the caret in the middle so ArrowLeft has somewhere to go inside the
|
||||
// field; if the player claimed the key, the video would seek instead.
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
expect(await currentTime(page)).toEqual(0);
|
||||
await expect(composer).toHaveValue('cursor keys belong to this box');
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
// Workspace member management, driven entirely from the browser.
|
||||
//
|
||||
// The API behind /api/workspaces/:id/members is well covered by the api suite.
|
||||
// What is not covered anywhere is the round trip: an owner invites someone in
|
||||
// one browser, that person accepts in another, and the role they end up with
|
||||
// decides what their pages render. This spec asserts on the *other* account's
|
||||
// view after every change the owner makes, because a permission change that the
|
||||
// owner's own page reports but the member's browser never sees is exactly the
|
||||
// bug an api-level test cannot find.
|
||||
//
|
||||
// Two things about the invite flow are worth knowing before reading on:
|
||||
//
|
||||
// 1. Inviting never adds a member directly, even when the address already
|
||||
// belongs to an account. app/api/workspaces/[workspaceId]/members/route.ts
|
||||
// always creates an Invitation and emails a link.
|
||||
// 2. SMTP is deliberately unset for this suite (see playwright.config.ts), so
|
||||
// nothing is delivered. The token is read out of the database instead. That
|
||||
// is the one shortcut here; everything on either side of it goes through the
|
||||
// UI.
|
||||
import { test, expect, storageStateFor, type StorageState } from './fixtures';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* The invitation link the owner's invite would have emailed.
|
||||
*
|
||||
* Scoped to the address the test just invited, so it cannot pick up a row from
|
||||
* a parallel worker.
|
||||
*/
|
||||
async function invitationTokenFor(email: string): Promise<string> {
|
||||
const invitation = await db.invitation.findFirst({
|
||||
where: { email, scope: 'WORKSPACE', status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { token: true },
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new Error(`No pending workspace invitation was created for ${email}.`);
|
||||
}
|
||||
return invitation.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The member row for `email` in the Current Members card.
|
||||
*
|
||||
* The rows carry no role, no heading and no test id, so the only stable anchor
|
||||
* is the address itself: the <p> holding it, then three levels up to the row
|
||||
* div that also holds the role select and the remove button. See
|
||||
* components/members-management-page.tsx.
|
||||
*/
|
||||
function memberRowFor(page: import('@playwright/test').Page, email: string) {
|
||||
return page.getByText(email, { exact: true }).locator('xpath=ancestor::div[3]');
|
||||
}
|
||||
|
||||
test('an invited member accepts, is promoted, and is removed, and their own pages follow', async ({
|
||||
page,
|
||||
browser,
|
||||
playwright,
|
||||
baseURL,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
// `confirm()` guards the remove button. Playwright dismisses dialogs by
|
||||
// default, which would make the DELETE never fire and the assertion below
|
||||
// fail for a reason that has nothing to do with the product.
|
||||
page.on('dialog', (dialog) => void dialog.accept());
|
||||
|
||||
const workspace = await seed.workspace(seededUser);
|
||||
const member = await seed.user({ name: 'Invited Reviewer' });
|
||||
const memberEmail = member.email ?? '';
|
||||
expect(memberEmail).not.toEqual('');
|
||||
|
||||
const memberState: StorageState = await storageStateFor(
|
||||
playwright.request,
|
||||
baseURL ?? '',
|
||||
memberEmail
|
||||
);
|
||||
const memberContext = await browser.newContext({ storageState: memberState });
|
||||
|
||||
try {
|
||||
const memberPage = await memberContext.newPage();
|
||||
|
||||
// --- before the invitation ----------------------------------------------
|
||||
// A stranger to the workspace is bounced off it entirely. This is the
|
||||
// control for every "the member can see it now" assertion further down.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}`);
|
||||
await expect(memberPage).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// --- the owner invites --------------------------------------------------
|
||||
await page.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(page.getByText('No members yet. Invite someone above.')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email Address').fill(memberEmail);
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await expect(page.getByText(`Invitation sent to ${memberEmail}`)).toBeVisible();
|
||||
// The pending list is the owner-visible proof that a row was written; the
|
||||
// success banner alone would also appear for a no-op.
|
||||
await expect(page.getByText('No pending invitations.')).toHaveCount(0);
|
||||
await expect(page.getByText(memberEmail, { exact: true })).toBeVisible();
|
||||
|
||||
// --- the member accepts -------------------------------------------------
|
||||
const token = await invitationTokenFor(memberEmail);
|
||||
await memberPage.goto(`/invitations/accept?token=${token}`);
|
||||
|
||||
// Accepting lands on the workspace it was for, which is itself the first
|
||||
// proof that the membership row now exists: the same URL redirected to
|
||||
// /dashboard a moment ago.
|
||||
await expect(memberPage).toHaveURL(
|
||||
new RegExp(`/workspaces/${workspace.id}\\?invite=accepted$`)
|
||||
);
|
||||
await expect(memberPage.getByRole('heading', { name: workspace.name })).toBeVisible();
|
||||
|
||||
// COMMENTATOR is the role that was sent, so the management controls must
|
||||
// not be there.
|
||||
await expect(memberPage.getByRole('link', { name: 'Members' })).toHaveCount(0);
|
||||
await expect(memberPage.getByRole('link', { name: 'Settings' })).toHaveCount(0);
|
||||
|
||||
// And the page behind that button is refused, not merely unlinked.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(memberPage).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// --- the owner promotes them to ADMIN -----------------------------------
|
||||
await page.reload();
|
||||
const memberRow = memberRowFor(page, memberEmail);
|
||||
const roleSelect = memberRow.getByRole('combobox');
|
||||
await expect(roleSelect).toContainText('Commentator');
|
||||
|
||||
await roleSelect.click();
|
||||
await page.getByRole('option', { name: 'Admin' }).click();
|
||||
await expect(roleSelect).toContainText('Admin');
|
||||
|
||||
// The member's own browser has to see the new role, not just the owner's.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}`);
|
||||
await expect(memberPage.getByRole('link', { name: 'Members' })).toBeVisible();
|
||||
|
||||
await memberPage.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(memberPage.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||
// An admin sees the owner as well as themselves, so the empty-state line is
|
||||
// the wrong thing to look for; the owner's address is the right one.
|
||||
await expect(memberPage.getByText(seededUser.email ?? '', { exact: true })).toBeVisible();
|
||||
|
||||
// --- the owner removes them ---------------------------------------------
|
||||
await page.reload();
|
||||
const rowToRemove = memberRowFor(page, memberEmail);
|
||||
await rowToRemove.getByRole('button').last().click();
|
||||
|
||||
await expect(page.getByText('No members yet. Invite someone above.')).toBeVisible();
|
||||
|
||||
// Back to where the spec started: no access at all.
|
||||
await memberPage.goto(`/workspaces/${workspace.id}`);
|
||||
await expect(memberPage).toHaveURL(/\/dashboard$/);
|
||||
} finally {
|
||||
await memberContext.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a commentator cannot invite anyone, and the owner can withdraw a pending invitation', async ({
|
||||
page,
|
||||
browser,
|
||||
playwright,
|
||||
baseURL,
|
||||
seed,
|
||||
seededUser,
|
||||
}) => {
|
||||
const workspace = await seed.workspace(seededUser);
|
||||
const commentator = await seed.user({ name: 'Commentator Only' });
|
||||
const commentatorEmail = commentator.email ?? '';
|
||||
|
||||
// Seeded directly rather than invited through the UI: the invite-then-accept
|
||||
// path is what the test above exists for, and repeating it here would double
|
||||
// this spec's runtime to set up a precondition.
|
||||
await db.workspaceMember.create({
|
||||
data: { workspaceId: workspace.id, userId: commentator.id, role: 'COMMENTATOR' },
|
||||
});
|
||||
|
||||
const commentatorContext = await browser.newContext({
|
||||
storageState: await storageStateFor(playwright.request, baseURL ?? '', commentatorEmail),
|
||||
});
|
||||
|
||||
try {
|
||||
const commentatorPage = await commentatorContext.newPage();
|
||||
|
||||
// The member page is the only route to the invite form, and 'manage' intent
|
||||
// sends a commentator away from it.
|
||||
await commentatorPage.goto(`/workspaces/${workspace.id}/members`);
|
||||
await expect(commentatorPage).toHaveURL(/\/dashboard$/);
|
||||
await expect(commentatorPage.getByLabel('Email Address')).toHaveCount(0);
|
||||
|
||||
// --- the owner invites a third party and then withdraws it --------------
|
||||
const outsiderEmail = `e2e-withdrawn-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto(`/workspaces/${workspace.id}/members`);
|
||||
await page.getByLabel('Email Address').fill(outsiderEmail);
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await expect(page.getByText(`Invitation sent to ${outsiderEmail}`)).toBeVisible();
|
||||
|
||||
const invitationRow = page
|
||||
.getByText(outsiderEmail, { exact: true })
|
||||
.locator('xpath=ancestor::div[2]');
|
||||
await invitationRow.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByText('Invitation canceled')).toBeVisible();
|
||||
await expect(page.getByText('No pending invitations.')).toBeVisible();
|
||||
|
||||
// Withdrawn for real: the token that was minted no longer opens anything.
|
||||
const withdrawn = await db.invitation.findFirst({
|
||||
where: { email: outsiderEmail },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(withdrawn?.status).toEqual('CANCELED');
|
||||
} finally {
|
||||
// The invitation row hangs off the workspace, which hangs off the owner, so
|
||||
// the Seed's own user cleanup takes it with it.
|
||||
await commentatorContext.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user