test: add unit, API, component and end-to-end test suites

The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
This commit is contained in:
yusufipk
2026-07-26 11:17:26 +07:00
parent 52b2c8d2a9
commit 1d099c68f2
101 changed files with 27625 additions and 122 deletions
View File
+87
View File
@@ -0,0 +1,87 @@
// Two users, two browser contexts: the project owner asks for approval and a
// project member grants it. Both sides then have to agree on what happened.
import { test, expect, storageStateFor } from './fixtures';
test('an approval request is raised by the owner and approved by a member', async ({
page,
browser,
playwright,
baseURL,
seed,
seededUser,
}) => {
const seeded = await seed.version(seededUser);
const approver = await seed.user({ name: 'Approving Member' });
await seed.member(seeded.project.id, approver.id, 'COMMENTATOR');
const videoUrl = `/projects/${seeded.project.id}/videos/${seeded.videoId}`;
// --- the owner raises the request ---------------------------------------
await page.goto(videoUrl);
await page.getByRole('button', { name: 'Approvals' }).click();
const panel = page.getByRole('dialog', { name: 'Approvals' });
await expect(panel.getByText('No approval requests yet.')).toBeVisible();
await panel.getByRole('button', { name: 'Request Approval' }).click();
const dialog = page.getByRole('dialog', { name: 'Request Approval' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: new RegExp(approver.email ?? '') }).click();
await expect(dialog.getByText('Approvers (1 selected)')).toBeVisible();
await dialog.getByRole('button', { name: 'Create Request' }).click();
await expect(dialog).toHaveCount(0);
// The sheet is still open behind the dialog, so there is nothing to reopen.
// The owner sees a pending request and can withdraw it, but cannot decide it:
// the owner is not on the approver list.
await expect(panel.getByText('1 request(s)')).toBeVisible();
// Exact, because `getByText('Pending')` is a case-insensitive substring match
// and the sheet also carries "respond to pending approvals" and a "Cancel
// Pending Request" button. What is left is the two labels this assertion is
// actually about: the request's own status badge and the single approver's
// decision row, both of which read Pending and nothing else.
const pendingLabels = panel.getByText('Pending', { exact: true });
await expect(pendingLabels).toHaveCount(2);
await expect(pendingLabels.first()).toBeVisible();
await expect(pendingLabels.last()).toBeVisible();
await expect(panel.getByRole('button', { name: 'Cancel Pending Request' })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Approve' })).toHaveCount(0);
// --- the member approves it ---------------------------------------------
const approverState = await storageStateFor(
playwright.request,
baseURL ?? '',
approver.email ?? ''
);
const approverContext = await browser.newContext({ baseURL, storageState: approverState });
try {
const approverPage = await approverContext.newPage();
await approverPage.goto(videoUrl);
await approverPage.getByRole('button', { name: 'Approvals' }).click();
const approverPanel = approverPage.getByRole('dialog', { name: 'Approvals' });
await expect(approverPanel.getByText('Your response is required')).toBeVisible();
await approverPanel.getByPlaceholder('Optional note').fill('Looks good to me.');
await approverPanel.getByRole('button', { name: 'Approve' }).click();
// Same two labels as above, now flipped: the request badge and the
// approver's own decision row.
const approvedLabels = approverPanel.getByText('Approved', { exact: true });
await expect(approvedLabels).toHaveCount(2);
await expect(approvedLabels.first()).toBeVisible();
await expect(approvedLabels.last()).toBeVisible();
await expect(approverPanel.getByRole('button', { name: 'Approve' })).toHaveCount(0);
} finally {
await approverContext.close();
}
// --- and the owner sees the decision ------------------------------------
await page.reload();
await page.getByRole('button', { name: 'Approvals' }).click();
await expect(panel.getByText('Approved', { exact: true })).toHaveCount(2);
// Exact again: the sheet description mentions pending approvals whatever the
// state of the request, so an unanchored match can never reach zero.
await expect(panel.getByText('Pending', { exact: true })).toHaveCount(0);
});
+106
View File
@@ -0,0 +1,106 @@
// The one spec that drives the real login and registration forms. Everything
// else signs in over HTTP through the fixture in fixtures.ts.
//
// `anonTest` gives database seeding with an anonymous browser.
import { anonTest as test, expect, E2E_PASSWORD } from './fixtures';
// Registration is rate limited to five requests per hour per IP, and the window
// lives in Postgres. tests/e2e/global-setup.ts empties that table before each
// run, so this file may spend a few of them; keep it to the two below.
const INVITE_CODE = 'test-invite';
test('an anonymous visitor to a protected route lands on the login page', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login$/);
// `CardTitle` renders a <div>, not a heading, so there is no role to match.
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('registration is refused when the invite code is wrong', async ({ page }) => {
await page.goto('/register');
await expect(page.getByText('Join OpenFrame to collaborate on video projects')).toBeVisible();
await page.getByLabel('Invite Code').fill('definitely-not-the-invite-code');
await page.getByLabel('Full Name').fill('Wrong Code Person');
await page.getByLabel('Email').fill(`e2e-rejected-${Date.now()}@example.com`);
await page.getByLabel('Password', { exact: true }).fill(E2E_PASSWORD);
await page.getByLabel('Confirm Password').fill(E2E_PASSWORD);
await page.getByRole('button', { name: 'Create Account' }).click();
await expect(page.getByText('Invalid invite code')).toBeVisible();
await expect(page).toHaveURL(/\/register$/);
});
test('a new account can be registered with the invite code and then signed in', async ({
page,
seed,
}) => {
const email = `e2e-registered-${Date.now()}@example.com`;
await page.goto('/register');
await page.getByLabel('Invite Code').fill(INVITE_CODE);
await page.getByLabel('Full Name').fill('Freshly Registered');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(E2E_PASSWORD);
await page.getByLabel('Confirm Password').fill(E2E_PASSWORD);
await page.getByRole('button', { name: 'Create Account' }).click();
// SMTP is unset for the app under test, so isEmailVerificationEnabled() is
// false and the account is auto-verified rather than parked on /verify-email.
await expect(page).toHaveURL(/\/login\?registered=true$/);
await expect(page.getByText('Account created successfully!')).toBeVisible();
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(E2E_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
// /settings, not /onboarding, and that is the real product behaviour rather
// than a test artefact: POST /api/auth/register does not set `trialEndsAt`, so
// with OPENFRAME_ENABLE_STRIPE on a brand new account has no billing access,
// and requireBillingAccessOrRedirect() on /dashboard sends it to billing
// before it ever sees the onboarding wizard.
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
// The Seed fixture only tracks rows it created itself, so remove this one by
// hand rather than leaving it behind for the next run.
await seed.deleteUserByEmail(email);
});
test('signing in with the wrong password is refused', async ({ page, seed }) => {
const user = await seed.user();
await page.goto('/login');
await page.getByLabel('Email').fill(user.email ?? '');
await page.getByLabel('Password').fill('not-the-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Invalid email or password')).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
});
test('signing in through the form reaches the dashboard, and signing out returns to login', async ({
page,
seed,
}) => {
const user = await seed.user();
await page.goto('/login');
await page.getByLabel('Email').fill(user.email ?? '');
await page.getByLabel('Password').fill(E2E_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: 'Projects', level: 1 })).toBeVisible();
await page.goto('/signout');
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL(/\/login$/);
// The session really is gone, not just navigated away from.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login$/);
});
+60
View File
@@ -0,0 +1,60 @@
// The billing gate, with OPENFRAME_ENABLE_STRIPE=true.
//
// The flag is deliberately on for this suite. With it off, hasBillingAccess()
// short-circuits to `true` and buildBillingAccessWhereInput() returns `{}`, so
// every assertion in this file would pass against a gate that does not exist.
// See playwright.config.ts.
//
// Nothing here clicks a checkout button: with dummy Stripe credentials that
// would be a real request to Stripe. The assertions stop at the button being
// offered.
import { anonTest as test, expect, signInPage } from './fixtures';
test('a user whose trial has ended is pushed to settings and cannot open a project', async ({
page,
seed,
}) => {
const expired = await seed.expiredUser();
const seeded = await seed.version(expired);
await signInPage(page, expired.email ?? '');
// The dashboard is not reachable: requireBillingAccessOrRedirect sends the
// user to /settings.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
await expect(page.getByText('Manage your billing access')).toBeVisible();
await expect(page.getByText('Billing access has ended.')).toBeVisible();
// The trial was consumed, so the offer is an upgrade rather than a new trial.
// Offered, not clicked.
await expect(page.getByRole('button', { name: 'Upgrade with Stripe' })).toBeEnabled();
// Their own project is closed to them too: computeProjectAccess() gates on the
// workspace owner's billing, and they are that owner.
await page.goto(`/projects/${seeded.project.id}`);
await expect(page).toHaveURL(/\/settings$/);
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
await expect(page).toHaveURL(/\/settings$/);
});
test('a user with an active trial reaches the dashboard and the project', async ({
page,
seed,
}) => {
// The other half of the same gate: without this, a broken redirect that sent
// everyone to /settings would look like a passing test above.
const active = await seed.user();
const seeded = await seed.version(active);
await signInPage(page, active.email ?? '');
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: 'Projects', level: 1 })).toBeVisible();
await page.goto(`/projects/${seeded.project.id}`);
await expect(page.getByRole('heading', { name: seeded.project.name, level: 1 })).toBeVisible();
});
+132
View File
@@ -0,0 +1,132 @@
// Commenting on a version.
//
// One deliberate limitation, stated here rather than papered over. The seeded
// version uses the `youtube` provider, so the playhead lives inside a YouTube
// iframe. Whether that iframe loads depends on the container reaching
// youtube.com, and a spec that only means something with internet access is a
// spec that fails in a sealed CI runner. So nothing here moves the playhead:
// every comment is left at 0:00 and asserted at 0:00, which is true in both
// environments.
//
// The consequence is that "the timecode links back to the right frame" is
// verified only as far as the control existing, carrying the captured time and
// being clickable. Verifying a seek needs a decodable media file behind a real
// object-storage version, which is video-upload.spec.ts's territory.
import { test, expect } from './fixtures';
test('a comment is posted and rendered with its author and timecode', async ({
page,
seed,
seededUser,
}) => {
const seeded = await seed.version(seededUser);
const body = `Colour grade looks warm ${Date.now()}`;
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
const composer = page.getByPlaceholder('Add a comment...');
await expect(composer).toBeVisible();
await expect(page.getByText('No comments yet')).toBeVisible();
await composer.fill(body);
// The send button is an icon with no accessible name. Cmd/Ctrl+Enter is the
// documented shortcut and the composer prints it under the field.
await composer.press('Control+Enter');
await expect(page.getByText(body)).toBeVisible();
await expect(page.getByText(seededUser.name ?? '')).toBeVisible();
await expect(page.getByText('No comments yet')).toHaveCount(0);
// Every comment carries a jump-to-timestamp control, and the playhead is at
// the start because the player never initialised (see the note above).
const timecode = page.getByTitle('Jump to this timestamp');
await expect(timecode).toBeVisible();
await expect(timecode).toContainText('0:00');
await timecode.click();
// Survives a reload, i.e. it was persisted and not only inserted optimistically.
await page.reload();
await expect(page.getByText(body)).toBeVisible();
});
test('an annotation drawn on the video is stored with the comment', async ({
page,
seed,
seededUser,
}) => {
const seeded = await seed.version(seededUser);
const body = `Fix this edge ${Date.now()}`;
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
await expect(page.getByPlaceholder('Add a comment...')).toBeVisible();
await page.getByTitle('Draw annotation on video').click();
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
// A stroke is only committed with at least two points, so there has to be a
// move between the press and the release, and it has to stay inside the box
// (leaving the canvas commits the stroke early).
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.3);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.5, { steps: 8 });
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.4, { steps: 8 });
await page.mouse.up();
await expect(page.getByText('Annotation attached')).toBeVisible();
const composer = page.getByPlaceholder('Add a comment...');
await composer.fill(body);
await composer.press('Control+Enter');
await expect(page.getByText(body)).toBeVisible();
await expect(page.getByText('Annotated')).toBeVisible();
await page.reload();
await expect(page.getByText(body)).toBeVisible();
await expect(page.getByText('Annotated')).toBeVisible();
});
test('a comment can be replied to and resolved', async ({ page, seed, seededUser }) => {
const seeded = await seed.version(seededUser);
const original = `Needs a tighter cut ${Date.now()}`;
const reply = `Agreed, trimming it ${Date.now()}`;
await seed.comment({
versionId: seeded.versionId,
authorId: seededUser.id,
content: original,
timestamp: 0,
});
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}`);
await expect(page.getByText(original)).toBeVisible();
// --- reply --------------------------------------------------------------
await page.getByRole('button', { name: 'Reply' }).click();
const replyBox = page.getByPlaceholder('Write a reply...');
await expect(replyBox).toBeVisible();
await replyBox.fill(reply);
await replyBox.press('Control+Enter');
await expect(page.getByText(reply)).toBeVisible();
// --- resolve ------------------------------------------------------------
// The resolve control is the unnamed icon button that sits beside the
// timecode in the comment's own header row.
await page
.getByTitle('Jump to this timestamp')
.locator('xpath=following-sibling::button[1]')
.click();
// Resolved comments drop out of the default list.
await expect(page.getByText(original)).toHaveCount(0);
// And come back when the filter asks for them.
await page.getByRole('button', { name: 'Resolved' }).click();
await expect(page.getByText(original)).toBeVisible();
});
+33
View File
@@ -0,0 +1,33 @@
// The only spec in the `mobile-chrome` project (see playwright.config.ts).
// A smoke test, not a second full pass: the navigation opens, the project list
// renders, and the page does not scroll sideways.
import { test, expect } from './fixtures';
test('the dashboard is usable on a phone viewport', async ({ page, seed, seededUser }) => {
const seeded = await seed.project(seededUser, { name: `Mobile Project ${Date.now()}` });
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Projects', level: 1 })).toBeVisible();
await expect(page.getByRole('link', { name: new RegExp(seeded.project.name) })).toBeVisible();
// No horizontal scroll. A single overflowing element makes the whole page pan,
// which is the most common way a responsive layout breaks.
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
// The desktop nav is collapsed behind the sheet trigger on this viewport.
await expect(page.getByRole('link', { name: 'Workspaces' })).toHaveCount(0);
await page.getByRole('button', { name: 'Toggle menu' }).click();
const menu = page.getByRole('dialog', { name: 'Navigation Menu' });
await expect(menu.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(menu.getByRole('link', { name: 'Workspaces' })).toBeVisible();
await menu.getByRole('link', { name: 'Workspaces' }).click();
await expect(page).toHaveURL(/\/workspaces$/);
});
+401
View File
@@ -0,0 +1,401 @@
// Fixtures for the end-to-end suite.
//
// Two things live here and nothing else: how a test gets its own data, and how a
// test gets a signed-in browser.
//
// `react-hooks/rules-of-hooks` is off for this file, and only for this file:
// Playwright names the second argument of a fixture `use`, and the rule reads
// every call to a function of that name as React's `use` hook, then objects
// that `seed`, `seededUser` and `storageState` are not components. Nothing in
// here renders anything.
/* eslint-disable react-hooks/rules-of-hooks */
// MUST stay the first import: it loads .env.test, and `@/lib/db` reads
// DATABASE_URL once at import time and memoizes the pool.
import '../helpers/env';
import { test as base, expect, type APIRequest, type APIRequestContext } from '@playwright/test';
import { ProjectMemberRole, ProjectVisibility, type Project, type User } from '@prisma/client';
import { db } from '@/lib/db';
import {
addProjectMember,
createComment,
createProject,
createShareLink,
createUser,
createVersion,
createVideo,
createWorkspace,
} from '../factories';
/** The password every seeded user gets. Never anything real. */
export const E2E_PASSWORD = 'e2e-password-123';
/**
* Unique-value source for the e2e suite.
*
* tests/factories/seq.ts restarts its counter per module load, which is enough
* for the api suite because resetDb() empties the database between tests. This
* suite deliberately does NOT truncate: several Playwright workers drive one
* app against one database at the same time, so `[email protected]` would
* collide between workers on the very first test. Every unique column therefore
* gets a value that is scoped to this process.
*
* These values are never asserted on, so the reproducibility argument that
* rules out randomness in the api factories does not apply.
*/
const RUN_TAG = `${process.pid.toString(36)}${Date.now().toString(36).slice(-5)}`;
let localSeq = 0;
function uniqueTag(): string {
localSeq += 1;
return `${RUN_TAG}-${localSeq}`;
}
export type StorageState = Awaited<ReturnType<APIRequestContext['storageState']>>;
// ---------------------------------------------------------------------------
// Seeding
// ---------------------------------------------------------------------------
export interface SeededProject {
owner: User;
project: Project;
workspaceId: string;
}
export interface SeededVersion extends SeededProject {
videoId: string;
versionId: string;
}
/**
* Row builders scoped to one test, plus the cleanup that goes with them.
*
* Every user this hands out is remembered, and `cleanup()` deletes them. The
* schema cascades from User to Workspace, Project, Video, VideoVersion, Comment
* and ShareLink, so deleting the users a test created removes everything the
* test created, including guest comments (which hang off the version, not off a
* user).
*/
export class Seed {
private readonly userIds: string[] = [];
/** A user with billing access (trial ends in seven days) who can sign in. */
async user(
overrides: { name?: string; onboardingCompletedAt?: Date | null } = {}
): Promise<User> {
const tag = uniqueTag();
const user = await createUser({
name: overrides.name ?? `E2E User ${tag}`,
email: `e2e-${tag}@example.com`,
password: E2E_PASSWORD,
onboardingCompletedAt:
overrides.onboardingCompletedAt === undefined
? new Date()
: overrides.onboardingCompletedAt,
});
this.userIds.push(user.id);
return user;
}
/**
* A user whose trial ran out and who has no subscription, so
* hasBillingAccess() is false. `billingTrialConsumedAt` is set, which is what
* makes /settings offer `Upgrade with Stripe` rather than `Start Free Trial`.
*/
async expiredUser(): Promise<User> {
const tag = uniqueTag();
const past = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const user = await createUser({
name: `E2E Expired ${tag}`,
email: `e2e-expired-${tag}@example.com`,
password: E2E_PASSWORD,
trialEndsAt: past,
billingTrialConsumedAt: past,
billingAccessEndedAt: past,
});
this.userIds.push(user.id);
return user;
}
/** A workspace owned by `owner`, with no projects in it yet. */
workspace(owner: User) {
const tag = uniqueTag();
return createWorkspace({
ownerId: owner.id,
name: `E2E Workspace ${tag}`,
slug: `e2e-workspace-${tag}`,
});
}
/** A workspace and a project inside it, owned by `owner`. */
async project(
owner: User,
overrides: { name?: string; visibility?: ProjectVisibility } = {}
): Promise<SeededProject> {
const tag = uniqueTag();
const workspace = await createWorkspace({
ownerId: owner.id,
name: `E2E Workspace ${tag}`,
slug: `e2e-workspace-${tag}`,
});
const project = await createProject({
ownerId: owner.id,
workspaceId: workspace.id,
name: overrides.name ?? `E2E Project ${tag}`,
slug: `e2e-project-${tag}`,
visibility: overrides.visibility ?? ProjectVisibility.PRIVATE,
});
return { owner, project, workspaceId: workspace.id };
}
/**
* A project with one video and one active version.
*
* The version is a `youtube` provider on purpose: it needs no object storage
* and no seeded media file. The player itself will not initialise without
* network access to youtube.com (see comments.spec.ts), but every page around
* it renders, which is what the comment and approval flows exercise.
*/
async version(
owner: User,
overrides: { name?: string; title?: string } = {}
): Promise<SeededVersion> {
const seeded = await this.project(owner, { name: overrides.name });
const tag = uniqueTag();
const video = await createVideo({
projectId: seeded.project.id,
title: overrides.title ?? `E2E Video ${tag}`,
});
const version = await createVersion({
videoParentId: video.id,
providerId: 'youtube',
providerVideoId: `dQw4w9WgXcQ`,
title: `E2E Version ${tag}`,
duration: 120,
});
return { ...seeded, videoId: video.id, versionId: version.id };
}
/** Adds `user` to `project` with the given role. */
member(projectId: string, userId: string, role: ProjectMemberRole) {
return addProjectMember({ projectId, userId, role });
}
/** A share link row, for the cases the UI cannot produce (expiry, for one). */
shareLink(input: {
projectId: string;
videoId?: string | null;
expiresAt?: Date | null;
password?: string;
}) {
return createShareLink({
projectId: input.projectId,
videoId: input.videoId ?? null,
token: `e2e-share-${uniqueTag()}`,
expiresAt: input.expiresAt ?? null,
...(input.password === undefined ? {} : { password: input.password }),
});
}
comment(input: { versionId: string; authorId: string; content: string; timestamp?: number }) {
return createComment(input);
}
/**
* Removes a user this Seed did not create, for the one case that exists: the
* account auth.spec.ts registers through the form.
*/
async deleteUserByEmail(email: string): Promise<void> {
await db.user.deleteMany({ where: { email } });
}
async cleanup(): Promise<void> {
if (this.userIds.length === 0) return;
await db.user.deleteMany({ where: { id: { in: this.userIds } } });
}
}
// ---------------------------------------------------------------------------
// Signing in
// ---------------------------------------------------------------------------
/**
* Signs a seeded user in over HTTP and leaves the session cookie in `context`.
*
* This is the NextAuth credentials callback, the same endpoint the login form
* posts to, driven without rendering the form. Only auth.spec.ts types into the
* form; every other spec pays two requests instead of a page load.
*/
export async function signInViaApi(
context: APIRequestContext,
email: string,
password: string = E2E_PASSWORD
): Promise<void> {
// Not optional. Every POST to /api/auth/* counts against a ten-per-fifteen-
// minutes budget shared by the whole run; see clearRateLimits().
await clearRateLimits();
const csrfResponse = await context.get('/api/auth/csrf');
if (!csrfResponse.ok()) {
throw new Error(
`GET /api/auth/csrf returned ${csrfResponse.status()}. ` +
'AUTH_TRUST_HOST must be set for the app under test, or NextAuth answers ' +
'every /api/auth/* request with UntrustedHost.'
);
}
const { csrfToken } = (await csrfResponse.json()) as { csrfToken?: string };
if (!csrfToken) {
throw new Error('GET /api/auth/csrf returned no csrfToken.');
}
const loginResponse = await context.post('/api/auth/callback/credentials', {
form: { csrfToken, email, password, callbackUrl: '/dashboard' },
maxRedirects: 0,
});
// NextAuth answers a successful credentials sign-in with a redirect to the
// callback URL and a failed one with a redirect back to /login?error=...
// Anything that is not a redirect at all is the rate limiter or a server
// error, and must fail here rather than as a mysterious /login later.
const status = loginResponse.status();
const location = loginResponse.headers()['location'] ?? '';
if (status !== 302 && status !== 303) {
throw new Error(
`POST /api/auth/callback/credentials returned ${status} for ${email} ` +
`(expected a redirect). Body: ${(await loginResponse.text()).slice(0, 200)}`
);
}
if (location.includes('/login')) {
throw new Error(`Credentials sign-in for ${email} was rejected (redirect to ${location}).`);
}
}
/**
* A `storageState` object holding a signed-in session for `email`.
*
* Built from an API request context rather than a browser context: no browser is
* launched, and `storageState` can therefore be an option fixture without
* depending on the `browser` fixture that consumes it.
*
* Pass the `playwright.request` fixture as `apiRequest`.
*/
export async function storageStateFor(
apiRequest: APIRequest,
baseURL: string,
email: string
): Promise<StorageState> {
const context = await apiRequest.newContext({ baseURL });
try {
await signInViaApi(context, email);
return await context.storageState();
} finally {
await context.dispose();
}
}
// ---------------------------------------------------------------------------
// The test object
// ---------------------------------------------------------------------------
/**
* Empties the DB-backed rate-limit table.
*
* This is not a convenience, it is the difference between a suite that works and
* one that does not. `app/api/auth/[...nextauth]/route.ts` wraps every POST to
* /api/auth/* in `rateLimit(request, 'login')`, which allows **ten requests per
* fifteen minutes per client IP** - and every worker, every context and every
* run share one IP here, because they are all loopback. The eleventh sign-in of
* the run gets a 429 instead of a session cookie, and the symptom is a test that
* quietly lands on /login. Registration (five per hour) has the same problem
* across retries.
*
* Switching the limiter off is not available: lib/rate-limit.ts throws on import
* when DISABLE_RATE_LIMIT is set and NODE_ENV is production, and the app under
* test is a production build. So the counters are cleared instead. The limiter
* itself is covered by tests/api/rate-limit.test.ts; no e2e spec asserts on it.
*/
export async function clearRateLimits(): Promise<void> {
await db.rateLimit.deleteMany({});
}
interface SeedFixtures {
/** Row builders for this test. Everything they create is deleted afterwards. */
seed: Seed;
/** Clears the rate-limit counters before the test runs. Always on. */
freshRateLimits: void;
}
interface SeedWorkerFixtures {
/** Closes the Prisma pool so the worker process can exit. */
dbConnection: void;
}
/**
* A test with database seeding but an **anonymous** browser.
*
* Use this for anything that has to start signed out: the login and
* registration forms, guest gates, share links opened by a stranger.
*/
export const anonTest = base.extend<SeedFixtures, SeedWorkerFixtures>({
dbConnection: [
async ({}, use) => {
await use();
await db.$disconnect();
},
{ scope: 'worker', auto: true },
],
freshRateLimits: [
async ({}, use) => {
await clearRateLimits();
await use();
},
{ auto: true },
],
seed: async ({}, use) => {
const seed = new Seed();
await use(seed);
await seed.cleanup();
},
});
/**
* The default: `page` already carries `seededUser`'s session, because the
* built-in `storageState` option is overridden below. No spec other than
* auth.spec.ts pays for rendering the login form.
*
* One trap comes with that override, and it is silent: Playwright Test passes
* the test's own context options into `browser.newContext()` as well, so a
* second context opened inside a test written against this `test` object is
* signed in as `seededUser` unless it says otherwise. Any test that needs a
* stranger must ask for one explicitly, with
* `browser.newContext({ storageState: undefined })`. A guest gate opened by an
* accidentally-authenticated context simply does not appear, and the spec looks
* like a product bug rather than a fixture mistake.
*/
export const test = anonTest.extend<{ seededUser: User }>({
seededUser: async ({ seed }, use) => {
await use(await seed.user());
},
storageState: async ({ playwright, baseURL, seededUser }, use) => {
await use(await storageStateFor(playwright.request, baseURL ?? '', seededUser.email ?? ''));
},
});
/**
* Signs `page`'s context in as `email`, for the rare test that needs a second
* identity in the same browser context. The API request context shares the
* cookie jar with the page, so the session applies from the next navigation on.
*/
export async function signInPage(
page: { context(): { request: APIRequestContext } },
email: string
): Promise<void> {
await signInViaApi(page.context().request, email);
}
export { expect };
+46
View File
@@ -0,0 +1,46 @@
// Runs once, in the Playwright main process, before the web server starts.
//
// Never import from '@playwright/test' here: globalSetup runs outside the test
// context, and importing the runner would pull in a second copy of it.
// MUST stay the first import: it loads .env.test, and everything below reads
// process.env.DATABASE_URL.
import '../helpers/env';
import { Pool } from 'pg';
import { setup as bootstrapSchema } from '../setup/db-global';
/**
* The rate limiter is Postgres-backed and keyed on the client IP, which is the
* same loopback address for every worker and for every run on this machine. Two
* consecutive suite runs would therefore share one window, and `register` allows
* five requests per hour. Clearing the table is what makes "green twice in a
* row" mean the same thing as "green once".
*
* It cannot be switched off instead: lib/rate-limit.ts throws on import when
* DISABLE_RATE_LIMIT is set and NODE_ENV is production, and the app under test
* is a production build.
*/
async function clearRateLimits(pool: Pool): Promise<void> {
await pool.query('DELETE FROM rate_limits');
}
export default async function globalSetup(): Promise<void> {
// Same bootstrap the api suite's globalSetup uses, so there is exactly one
// description of how a test database is built. It is idempotent: on a database
// that already has the schema it is a no-op `prisma db push`.
await bootstrapSchema();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 1,
connectionTimeoutMillis: 5_000,
});
pool.on('error', () => {});
try {
await clearRateLimits(pool);
} finally {
await pool.end();
}
}
+56
View File
@@ -0,0 +1,56 @@
// A fresh account walks the wizard and comes out with a workspace and a project.
//
// The account is seeded rather than registered through the form: registration is
// rate limited to five per hour per IP and auth.spec.ts already owns that path.
// What matters here is `onboardingCompletedAt: null`, which is what sends
// /dashboard to /onboarding.
import { anonTest as test, expect, signInPage } from './fixtures';
test('a fresh user creates a workspace and a project and lands on the dashboard', async ({
page,
seed,
}) => {
const stamp = Date.now();
const user = await seed.user({ name: 'Onboarding Person', onboardingCompletedAt: null });
const workspaceName = `Onboarded Workspace ${stamp}`;
const projectName = `Onboarded Project ${stamp}`;
await signInPage(page, user.email ?? '');
await page.goto('/dashboard');
// Step 1: welcome. The dashboard bounces an un-onboarded user here.
await expect(page).toHaveURL(/\/onboarding$/);
await expect(
page.getByRole('heading', { name: 'Welcome to OpenFrame, Onboarding!' })
).toBeVisible();
await expect(page.getByText('Step 1 of 5')).toBeVisible();
await page.getByRole('button', { name: 'Get Started' }).click();
// Step 2: workspace.
await expect(page.getByRole('heading', { name: 'Create your workspace' })).toBeVisible();
await page.getByLabel('Workspace Name').fill(workspaceName);
await page.getByRole('button', { name: 'Create Workspace' }).click();
// Step 3: project.
await expect(page.getByRole('heading', { name: 'Create your first project' })).toBeVisible();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('button', { name: 'Create Project' }).click();
// Step 4: informational.
await expect(page.getByRole('heading', { name: 'Adding videos' })).toBeVisible();
await page.getByRole('button', { name: 'Got it, continue' }).click();
// Step 5: notifications. `Skip` still posts /api/onboarding/complete.
await expect(page.getByRole('heading', { name: 'Notification preferences' })).toBeVisible();
await page.getByRole('button', { name: 'Skip', exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: 'Projects', level: 1 })).toBeVisible();
await expect(page.getByRole('link', { name: new RegExp(projectName) })).toBeVisible();
await expect(page.getByText(workspaceName)).toBeVisible();
// Onboarding does not run a second time.
await page.goto('/onboarding');
await expect(page).toHaveURL(/\/dashboard$/);
});
+85
View File
@@ -0,0 +1,85 @@
// Create, rename, change visibility, delete. The dashboard list has to reflect
// every step, because that list is the one page every user sees first.
import { test, expect } from './fixtures';
test('a project can be created, renamed, made public and deleted', async ({
page,
seed,
seededUser,
}) => {
const stamp = Date.now();
const originalName = `Lifecycle Project ${stamp}`;
const renamed = `Lifecycle Renamed ${stamp}`;
// A project needs a workspace; /projects/new offers nothing without one.
const workspace = await seed.workspace(seededUser);
// --- create -------------------------------------------------------------
await page.goto('/dashboard');
await page.getByRole('link', { name: 'New Project' }).click();
await expect(page.getByText('Create New Project')).toBeVisible();
// A single workspace is auto-selected by the page, so assert that rather than
// driving a combobox that may not need driving.
await expect(page.getByRole('combobox')).toContainText(workspace.name);
await page.getByLabel('Project Name').fill(originalName);
await page.getByRole('button', { name: 'Private' }).click();
await page.getByRole('button', { name: 'Create Project' }).click();
// The form itself lives at /projects/new, which also matches
// `/projects/<segment>`, so the wait has to exclude that one segment by name.
// Without the exclusion the URL is read while the form is still on screen and
// `projectId` comes out as the literal string `new`, which sends every later
// step to a project that does not exist.
await expect(page).toHaveURL(/\/projects\/(?!new$)[^/]+$/);
const projectId = new URL(page.url()).pathname.split('/').pop() ?? '';
expect(projectId).not.toEqual('');
await expect(page.getByRole('heading', { name: originalName })).toBeVisible();
await page.goto('/dashboard');
const card = page.getByRole('link', { name: new RegExp(originalName) });
await expect(card).toBeVisible();
await expect(card).toContainText('private');
// --- rename and change visibility --------------------------------------
await page.goto(`/projects/${projectId}/settings`);
await expect(page.getByText('Project Settings')).toBeVisible();
await page.getByLabel('Project Name').fill(renamed);
// Anchored, because the download toggle on this page carries "On public
// projects this includes unauthenticated visitors." in its accessible name and
// an unanchored 'Public' would match both buttons.
await page.getByRole('button', { name: /^Public/ }).click();
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(page.getByText('Project settings saved successfully')).toBeVisible();
await page.goto('/dashboard');
await expect(page.getByRole('link', { name: new RegExp(originalName) })).toHaveCount(0);
const renamedCard = page.getByRole('link', { name: new RegExp(renamed) });
await expect(renamedCard).toBeVisible();
await expect(renamedCard).toContainText('public');
// --- delete -------------------------------------------------------------
await page.goto(`/projects/${projectId}/settings`);
await page.getByRole('button', { name: 'Delete', exact: true }).click();
const dialog = page.getByRole('alertdialog');
await expect(dialog.getByRole('heading', { name: `Delete "${renamed}"?` })).toBeVisible();
// The confirm button stays disabled until the name is typed exactly.
const confirm = dialog.getByRole('button', { name: 'Delete Project' });
await expect(confirm).toBeDisabled();
await dialog.getByLabel(/Type .* to confirm/).fill(renamed);
await expect(confirm).toBeEnabled();
await confirm.click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('link', { name: new RegExp(renamed) })).toHaveCount(0);
// Gone from the app, not just from the list.
await page.goto(`/projects/${projectId}`);
await expect(page.getByText('Project Not Found')).toBeVisible();
});
+132
View File
@@ -0,0 +1,132 @@
// Share links: the owner creates one, a stranger opens it, and an expired one is
// refused.
//
// Accessibility findings this spec has to work around, reported rather than
// hidden: neither the guest-name input (components/video-page/guest-name-gate.tsx)
// nor the password input (components/share-link-unlock.tsx) has a label, an
// aria-label or an id, and a password input has no ARIA role at all, so there is
// no getByRole route to either. They are located by placeholder here.
import { test, expect, anonTest } from './fixtures';
test('the owner creates a review link and a stranger opens it through the guest gate', async ({
page,
browser,
seed,
seededUser,
}) => {
const seeded = await seed.version(seededUser, { title: `Shared Video ${Date.now()}` });
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}/share`);
// The page is identified by its create control rather than by the card title
// "Share Video For Review": that title is server-rendered, and while the
// streamed markup is being swapped into place there are briefly two copies of
// it in the document (one still hidden), which is a strict-mode violation
// waiting to happen. The button is rendered only by the client, after the
// link settings have loaded, so there is only ever one of it, and waiting for
// it also means the fetch behind "Loading link settings..." has finished.
const createLink = page.getByRole('button', { name: 'Create Review Link' });
await expect(createLink).toBeVisible();
await createLink.click();
const linkField = page.locator('input[readonly]');
await expect(linkField).toBeVisible();
const shareUrl = await linkField.inputValue();
expect(shareUrl).toContain('/watch/');
expect(shareUrl).toContain('shareToken=');
// A stranger, in a context with no session at all.
//
// `storageState: undefined` is load-bearing. Playwright Test feeds the test's
// own context options into `browser.newContext()`, and this file's `test`
// fixture sets `storageState` to the seeded owner's session, so a bare
// `newContext()` opens the share link as the owner: `/api/watch/:id` then
// answers `isAuthenticated: true`, `isGuest` is false in
// components/video-page-content.tsx, and the guest gate is skipped.
const guestContext = await browser.newContext({ storageState: undefined });
try {
const guestPage = await guestContext.newPage();
await guestPage.goto(shareUrl);
// The bootstrap page exchanges the token for a share session cookie and
// then lands on the clean watch URL.
await expect(guestPage).toHaveURL(new RegExp(`/watch/${seeded.videoId}$`));
// Guest name gate: no account, so the visitor has to say who they are.
await expect(guestPage.getByRole('heading', { name: 'Welcome to OpenFrame' })).toBeVisible();
await guestPage.getByPlaceholder('Your name').fill('Passing Reviewer');
await guestPage.getByRole('button', { name: 'Continue' }).click();
// The permission level on a link created through this UI is COMMENT, so the
// guest gets a comment composer, not a read-only page.
await expect(guestPage.getByPlaceholder('Add a comment...')).toBeVisible();
} finally {
await guestContext.close();
}
});
test('a password on the link puts an unlock form in front of the video', async ({
page,
browser,
seed,
seededUser,
}) => {
const seeded = await seed.version(seededUser);
await page.goto(`/projects/${seeded.project.id}/videos/${seeded.videoId}/share`);
await page.getByRole('button', { name: 'Create Review Link' }).click();
await expect(page.locator('input[readonly]')).toBeVisible();
// Setting a password rotates the token, so read the URL only afterwards.
await page.locator('input[type="password"]').fill('share-secret-123');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByRole('button', { name: 'Remove' })).toBeVisible();
const shareUrl = await page.locator('input[readonly]').inputValue();
// Anonymous on purpose; see the note in the first test about why the
// `storageState` override is required here.
const guestContext = await browser.newContext({ storageState: undefined });
try {
const guestPage = await guestContext.newPage();
await guestPage.goto(shareUrl);
await expect(guestPage).toHaveURL(/unlock=1$/);
await expect(guestPage.getByRole('heading', { name: 'Password Required' })).toBeVisible();
// Wrong password first, so the assertion below is about the password and not
// about the form merely existing.
await guestPage.locator('input[type="password"]').fill('not-the-password');
await guestPage.getByRole('button', { name: 'Continue' }).click();
await expect(guestPage.getByText('Invalid password')).toBeVisible();
await guestPage.locator('input[type="password"]').fill('share-secret-123');
await guestPage.getByRole('button', { name: 'Continue' }).click();
await expect(guestPage).toHaveURL(new RegExp(`/watch/${seeded.videoId}$`));
await guestPage.getByPlaceholder('Your name').fill('Unlocked Reviewer');
await guestPage.getByRole('button', { name: 'Continue' }).click();
await expect(guestPage.getByPlaceholder('Add a comment...')).toBeVisible();
} finally {
await guestContext.close();
}
});
// The UI has no expiry control at all (permission and expiresAt are fixed
// server-side), so the expired row is seeded directly. That is the only way to
// cover the branch, and it is a gap worth knowing about: an expiring link cannot
// be created through the product.
anonTest('an expired share link is refused', async ({ page, seed }) => {
const owner = await seed.user();
const seeded = await seed.version(owner);
const link = await seed.shareLink({
projectId: seeded.project.id,
videoId: seeded.videoId,
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
});
await page.goto(`/watch/${seeded.videoId}?shareToken=${link.token}`);
await expect(page.getByText('Share session is invalid')).toBeVisible();
await expect(page.getByPlaceholder('Add a comment...')).toHaveCount(0);
});
+80
View File
@@ -0,0 +1,80 @@
// The direct upload path, end to end: the browser presigns against the app,
// PUTs the bytes straight at MinIO, and the app finalises the version.
//
// This needs the `e2e` compose profile up (minio-test plus its bucket) and the
// R2_* variables from playwright.config.ts. Without them
// isDirectFileUploadEnabled() is false and the `Direct Upload` tab is not
// rendered at all, so the first assertion below fails loudly rather than
// silently testing nothing.
//
// Note on TESTING.md section 6: it says to upload "through the drag-drop
// uploader". The uploader on the dashboard and project pages
// (components/video-drag-drop-uploader.tsx) has no <input type="file"> at all,
// only window-level drop listeners, so setInputFiles cannot reach it. The real
// upload form is /projects/{id}/videos/new, which does have a file input, and
// that is what this spec drives.
import path from 'node:path';
import { test, expect } from './fixtures';
import { REPO_ROOT } from '../helpers/env';
const SAMPLE_VIDEO = path.join(REPO_ROOT, 'tests', 'fixtures', 'sample.mp4');
// The upload is three network round trips plus a MinIO PUT, and `next build`
// output is cold on the first hit of each route.
test.setTimeout(120_000);
test('a video file is uploaded to object storage and a second version is added', async ({
page,
seed,
seededUser,
}) => {
const { project } = await seed.project(seededUser);
const title = `Uploaded Video ${Date.now()}`;
await page.goto(`/projects/${project.id}/videos/new`);
// `CardTitle` renders a <div>, so the page is identified by its tab strip.
await expect(page.getByRole('tab', { name: 'Paste URL' })).toBeVisible();
// Present only when the app resolved a direct upload provider.
const directUploadTab = page.getByRole('tab', { name: 'Direct Upload' });
await expect(directUploadTab).toBeVisible();
await directUploadTab.click();
await page.getByLabel('Video Files').setInputFiles(SAMPLE_VIDEO);
await expect(page.getByText('sample.mp4')).toBeVisible();
await page.getByLabel('Title').fill(title);
await page.getByRole('button', { name: 'Add Video', exact: true }).click();
// Back on the project page with the video listed. A failed presign or a
// rejected PUT would leave us on the form with an error instead.
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}$`), { timeout: 90_000 });
const videoHeading = page.getByRole('heading', { name: title, level: 3 });
await expect(videoHeading).toBeVisible();
// --- second version ------------------------------------------------------
await videoHeading.click();
await expect(page).toHaveURL(new RegExp(`/projects/${project.id}/videos/[^/]+$`));
// One version so far, so there is nothing to compare against yet.
await expect(page.getByText('v1', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Compare' })).toHaveCount(0);
await page.getByRole('button', { name: 'New Version' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Add New Version' })).toBeVisible();
await dialog.getByRole('tab', { name: 'Upload File' }).click();
await dialog.getByLabel('Video File').setInputFiles(SAMPLE_VIDEO);
// Located by placeholder, not by label: the "Version Label (optional)" <Label>
// in components/video-page/version-actions-dialog.tsx has no htmlFor and the
// <Input> next to it has no id, so the two are not associated and there is no
// accessible name to match. Another entry for the accessibility list.
await dialog.getByPlaceholder('e.g. Final Cut, Review Round 2').fill('Round 2');
await dialog.getByRole('button', { name: 'Add Version 2' }).click();
// The new version becomes the active one, which is also what makes the
// compare view reachable.
await expect(page.getByText('v2', { exact: true })).toBeVisible({ timeout: 90_000 });
await expect(page.getByRole('button', { name: 'Compare' })).toBeVisible();
});