Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/lint-type-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,8 @@ jobs:
- name: Run build
run: pnpm build

- name: Install Playwright browsers
run: pnpm --filter client exec playwright install --with-deps chromium

- name: Run tests
run: pnpm test
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules

.env
.env.test
.env.test
# Git worktrees
.worktrees/
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ Real-time collaborative Markdown editor. pnpm workspace monorepo: `client/` (Rea
- Hocuspocus WS is mounted at `/collaboration` on the same Express app and port (`server.ts` calls `app.ws('/collaboration', ...)`) — no separate WS port. Yjs state persistence is `server/src/lib/dbPersistence.ts` (`@hocuspocus/extension-database`).
- Schema lives in `server/prisma/schema.prisma` (Prisma 6). Use `db:migrate` (dev, creates migrations) / `db:migrate:prod` (deploy); CI runs `db:migrate:prod`.

## Client conventions (enforced by lint — see `client/eslint.config.js`)

- Folder-per-component: `ComponentName/{component-name.tsx, index.ts, component-name.stories.tsx}`. **Directories PascalCase, all `.ts`/`.tsx` files kebab-case** (`check-file/filename-naming-convention`, `ignoreMiddleExtensions: true`).
- Composite "kit" folders (e.g. `ui/Form`) may hold small internal parts flat when they have no external consumers; the external API goes through the folder barrel.
- Every export carries JSDoc (`jsdoc/require-jsdoc` at `error`). One concise block per component/hook/util.
- Component props: document each **custom** prop with a one-line JSDoc on its member in the Props interface/type (e.g. `/** Visual style preset. */ variant?: 'default' | 'ghost';`). Note defaults established in implementation when not obvious from the type (`Defaults to 'default'.`); never re-document inherited React/Radix props, and don't restate what the type already says. Lint enforces non-empty descriptions (`jsdoc/require-description` in components/hooks/features); member-level completeness is a review responsibility.
- Hook/helper params: `@param` for every param wherever a JSDoc block exists in `src/hooks/**` / `src/features/**/*.ts` (`jsdoc/require-param`, destructured-object members exempt); add `@returns` when the return value is non-obvious. Component functions stay exempt — their props live on the Props interface.
- Every story meta has `tags: ['autodocs']`; plop (`pnpm --filter client generate`) scaffolds this automatically.
- Component tests = Storybook interaction tests: state stories for every variant/boolean prop + `play()` assertions using `{ expect, fn, userEvent, within } from 'storybook/test'`. Run via `pnpm --filter client test` (browser mode, needs `playwright install chromium` once).
- Story gotchas:
- Never pass complex objects (e.g. CodeMirror views) through story `args` — Storybook JSON-serializes args and circular refs hang the run forever. Build them inside `render()` instead.
- Radix portals render outside the story canvas — query `within(document.body)` for dropdown/modal content.
- Animated Radix open/close: prefer existence-based assertions (`findByText`) over `toBeVisible()`, and `waitFor(..., { timeout })` before asserting unmount.
- First run after adding a heavy dep to stories can fail imports mid-run while Vite optimizes deps — add it to `optimizeDeps.include` in `client/vite.config.ts`.
- Global decorators live in `client/.storybook/preview.tsx`: every story is wrapped in `MemoryRouter` + an authenticated mock auth context. Override with `parameters.auth` (partial context) or `parameters.auth: null` for signed-out; `parameters.modal: true` adds a Modal ancestor for components emitting ModalContent parts.

## Docker

- `docker-compose.dev.yml`: full dev stack driven by **compose watch** (`develop.watch`). Images use dedicated `dev` stages with source baked in and nothing compiled at build time. Run `docker compose -f docker-compose.dev.yml up --build --watch` (or `pnpm docker:dev`); later runs can skip `--build`. Source edits sync into containers live (server: esbuild rebuild + nodemon restart; client: Vite HMR). Changes to package.json / lockfile / vite.config.ts trigger automatic rebuild+restart of that service. Schema changes: edit `schema.prisma`, then `docker compose -f docker-compose.dev.yml exec server pnpm exec prisma migrate dev`. Prisma Studio is opt-in via `docker compose -f docker-compose.dev.yml --profile tools up studio` (port 5555, runs from the `generated` stage).
Expand Down
32 changes: 0 additions & 32 deletions client/.storybook/preview.ts

This file was deleted.

89 changes: 89 additions & 0 deletions client/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Decorator, Preview } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router';

import { Modal } from '@/components/ui/Modal';
import { AuthContext, type AuthContextType } from '@/context/auth/auth-context';
import type { User } from '@/types/api';
import '../src/index.css';

const mockUser: User = {
id: 'story-user-1',
email: 'story@example.com',
username: 'storyuser',
fullName: 'Story User',
};

const defaultAuth: AuthContextType = {
user: mockUser,
accessToken: 'storybook-access-token',
isAuthenticated: true,
loading: false,
login: () => {},
logout: async () => {},
};

/**
* Wraps every story in a router and an authenticated auth context.
* Override per story via parameters.auth (partial AuthContextType, or
* `null` to render the signed-out state).
*/
const withRouterAndAuth: Decorator = (Story, context) => {
const override = context.parameters.auth;
const value =
override === null
? {
...defaultAuth,
user: null,
accessToken: null,
isAuthenticated: false,
}
: override
? { ...defaultAuth, ...override }
: defaultAuth;
return (
<MemoryRouter>
<AuthContext.Provider value={value}>
<Story />
</AuthContext.Provider>
</MemoryRouter>
);
};

/**
* Opt-in modal ancestor for components that emit ModalContent parts
* directly (e.g. NewDocumentFormBody). Enable via parameters.modal = true.
*/
const withOptionalModal: Decorator = (Story, context) => {
if (!context.parameters.modal) return <Story />;
return (
<Modal open onOpenChange={() => {}}>
<Story />
</Modal>
);
};

const preview: Preview = {
decorators: [withRouterAndAuth, withOptionalModal],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
backgrounds: {
options: {
dark: { name: 'Dark', value: '#18181b' },
light: { name: 'Light', value: '#F7F9F2' },
},
},
a11y: {
test: 'todo',
},
},
initialGlobals: {
backgrounds: { value: 'dark' },
},
};

export default preview;
14 changes: 13 additions & 1 deletion client/e2e/collaboration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import {

import { getSharePath, openDocumentEditor } from './utils';

const API_URL = process.env.E2E_API_URL ?? 'http://localhost:5000';
const API_URL = process.env.E2E_API_URL ?? 'http://localhost:5001';

// The second user is created via the API: the registration form is already
// covered by auth.spec.ts and proved flaky to drive from a second context.
/**
* Register a throwaway second user via the API for collaboration specs.
*/
async function createUserViaApi(requestCtx: APIRequestContext, suffix: number) {
const res = await requestCtx.post(`${API_URL}/api/auth/register`, {
data: {
Expand All @@ -24,6 +27,9 @@ async function createUserViaApi(requestCtx: APIRequestContext, suffix: number) {
expect(res.status()).toBe(201);
}

/**
* Log the second user in through the login UI.
*/
async function loginUser2(page: Page, suffix: number) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(`e2e-${suffix}@test.local`);
Expand All @@ -34,6 +40,9 @@ async function loginUser2(page: Page, suffix: number) {

// Owner resolves the pending request from the Share menu. The hook fetching
// join requests runs once on mount (no polling), so the page is reloaded first.
/**
* Owner approves or rejects a pending collaboration request via the UI.
*/
async function resolvePendingRequest(
page: Page,
username: string,
Expand Down Expand Up @@ -61,6 +70,9 @@ async function resolvePendingRequest(
// other client's editor. Retried because a CodeMirror remount (awareness
// updates, provider reconnect) can swallow a click's focus, dropping the
// whole keystroke burst.
/**
* Type into one client and wait for the text to appear in the other.
*/
async function typeAndSync(from: Page, to: Page, text: string) {
const content = from.locator('.cm-content').first();
for (let attempt = 0; attempt < 3; attempt++) {
Expand Down
3 changes: 3 additions & 0 deletions client/e2e/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { createTestDocument, getDocumentCard } from './utils';

const MARKDOWN = '# Hello E2E\n\nSome *emphasis* text.';

/**
* Open an existing dashboard document in the editor.
*/
async function openInEditor(page: Page, title: string) {
await getDocumentCard(page, title).first().click();
await expect(page).toHaveURL(/.*\/app\/doc\/.+/);
Expand Down
83 changes: 83 additions & 0 deletions client/e2e/sharing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ test.describe('Document Sharing', () => {
await expect(page).toHaveURL(/.*\/app\/doc\/.+/);
});

/**
* Open the share dialog for the current document.
*/
async function openShareMenu(page: Page) {
await page.getByRole('button', { name: 'Share' }).click();

Expand Down Expand Up @@ -50,6 +53,86 @@ test.describe('Document Sharing', () => {
expect(new URL(clipboard).pathname).toBe(sharePath);
});

/**
* Decode the permission claim from a share-link JWT.
*/
function tokenPermission(shareUrl: string): string {
const token = new URL(shareUrl).pathname.split('/').pop() ?? '';
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString(),
);
return payload.permission;
}

test('should copy an edit-mode link right after switching permission', async ({
page,
}) => {
// Slow down share-link responses so the copy lands while the
// post-switch refetch is still in flight (exposes stale-link races)
await page.route('**/share-link*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});

await openShareMenu(page); // initial view-mode link loaded

const menu = page.getByRole('menu');
await menu.getByRole('combobox').click();
await page.getByRole('option', { name: 'Edit mode' }).click();

const copyButton = menu.getByRole('button').first();
await expect(copyButton).toBeEnabled();
await copyButton.click({ force: true }); // skip radix open-animation checks

await expect(page.getByRole('status')).toContainText(/cop{2}ied|copied/i);

const clipboard = await page.evaluate(() => navigator.clipboard.readText());
expect(new URL(clipboard).pathname).toContain('/app/doc/share/');
// The copied token must match the newly selected mode, not the previous one
expect(tokenPermission(clipboard)).toBe('edit');
});

test('should not keep a copyable link after a failed refetch', async ({
page,
}) => {
// Only the post-switch edit fetch fails; earlier view fetches
// (including StrictMode's dev double-invoke) succeed
await page.route('**/share-link*', async (route) => {
if (route.request().url().includes('permission=edit')) {
return route.abort('connectionrefused');
}
return route.continue();
});

await openShareMenu(page); // view link loaded, copy enabled

const menu = page.getByRole('menu');
await menu.getByRole('combobox').click();
await page.getByRole('option', { name: 'Edit mode' }).click();

// The failed edit fetch must invalidate the old view link entirely:
// nothing copyable, and no view URL displayed under an "Edit mode" select
await expect(menu.getByText('Failed to fetch share link')).toBeVisible();
await expect(menu.locator('p').first()).toHaveText(/No link available/);
await expect(menu.getByRole('button').first()).toBeDisabled();

// Recovery: switching back to View (not intercepted) must clear the
// error and restore a working, copyable link without a page reload
await menu.getByRole('combobox').click();
await page.getByRole('option', { name: 'View mode' }).click();

const recoveredLink = menu.locator('p').first();
await expect(recoveredLink).toHaveText(/\/app\/doc\/share\/\S+/);
await expect(menu.getByText('Failed to fetch share link')).toBeHidden();
const recoveredCopy = menu.getByRole('button').first();
await expect(recoveredCopy).toBeEnabled();
await recoveredCopy.click({ force: true }); // skip radix open-animation checks
const clipboardAfterRecovery = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(tokenPermission(clipboardAfterRecovery)).toBe('view');
});

test('should grant access through the share link', async ({ page }) => {
const sharePath = await openShareMenu(page);

Expand Down
15 changes: 15 additions & 0 deletions client/e2e/utils.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { expect, type Page } from '@playwright/test';

/**
* Locate a document card link by its title.
*/
export function getDocumentCard(page: Page, title: string) {
return page.getByRole('link').filter({ hasText: title });
}

/**
* Open a document card's action dropdown.
*/
export async function openDocumentMenu(page: Page, title: string) {
const card = getDocumentCard(page, title);
await expect(card).toBeVisible();
await card.getByRole('button', { name: 'options' }).click();
await expect(page.getByRole('menu')).toBeVisible();
}

/**
* Create a document end-to-end from the dashboard.
*/
export async function createTestDocument(page: Page, title: string) {
await page.goto('/app');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Expand All @@ -27,6 +36,9 @@ export async function createTestDocument(page: Page, title: string) {
await expect(getDocumentCard(page, title)).toBeVisible();
}

/**
* Create a document and open it in the editor.
*/
export async function openDocumentEditor(page: Page, title: string) {
await createTestDocument(page, title);
await getDocumentCard(page, title).first().click();
Expand All @@ -35,6 +47,9 @@ export async function openDocumentEditor(page: Page, title: string) {

// Server-generated links currently omit the port (e.g. http://localhost/...),
// so navigate by pathname against the test baseURL instead of the raw URL.
/**
* Extract the share-link path from the share dialog, rebased onto the test baseURL.
*/
export async function getSharePath(
page: Page,
permission?: 'view' | 'edit',
Expand Down
Loading
Loading