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 client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ storybook-static
/blob-report/
/playwright/.auth/
/playwright/.cache/

# Vitest browser failure screenshots
__screenshots__/
158 changes: 158 additions & 0 deletions client/src/hooks/__tests__/use-collaborators.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { act } from 'react';
import { createElement } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { api } from '@/lib/api';

vi.mock('@/lib/api', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));

import { useCollaborators } from '../use-collaborators';

const mockApi = vi.mocked(api, true);

/**
* Renders a hook inside a probe component mounted on document.body and
* exposes its latest return value, since no DOM-testing library is wired
* into the client suite.
*
* @param useHook - Hook factory invoked on every render of the probe.
* @returns The latest hook result plus unmount for cleanup.
*/
function renderHook<T>(useHook: () => T) {
let result!: T;
let root!: Root;

const Probe = () => {
result = useHook();
return null;
};

const host = document.createElement('div');
document.body.appendChild(host);

act(() => {
root = createRoot(host);
root.render(createElement(Probe) as ReactNode);
});

return {
get current() {
return result;
},
unmount: () => {
act(() => root.unmount());
host.remove();
},
};
}

/**
* Flushes pending React updates and microtasks. Polls until the
* predicate holds, so `await act(async () => {})` empty flushes don't
* flake when the initial fetch resolves on the next microtask.
*
* @param predicate - Condition to wait for.
* @param timeoutMs - Fail after this long.
*/
async function waitFor(predicate: () => boolean, timeoutMs = 1000) {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('waitFor timeout');
}
await act(async () => {
await new Promise<void>((r) => {
setTimeout(r, 0);
});
});
}
}

describe('useCollaborators', () => {
beforeEach(() => {
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
mockApi.get.mockResolvedValue({ data: [] });
});

afterEach(() => {
vi.clearAllMocks();
});

it('clears a stale add error when a retry succeeds', async () => {
const hook = renderHook(() => useCollaborators('doc1'));
await waitFor(() => !hook.current.loading);

mockApi.post.mockRejectedValueOnce(new Error('boom'));
await act(async () => {
await hook.current.addCollaborator('a@b.c');
});
expect(hook.current.error).toBe('Failed to add collaborator');

mockApi.post.mockResolvedValueOnce({});
let added = false;
await act(async () => {
added = await hook.current.addCollaborator('a@b.c');
});

expect(added).toBe(true);
expect(hook.current.error).toBeNull();
hook.unmount();
});

it('clears a stale remove error when a retry succeeds', async () => {
const hook = renderHook(() => useCollaborators('doc1'));
await waitFor(() => !hook.current.loading);

mockApi.delete.mockRejectedValueOnce(new Error('boom'));
await act(async () => {
await hook.current.removeCollaborator('u1');
});
expect(hook.current.error).toBe('Failed to remove collaborator');

mockApi.delete.mockResolvedValueOnce({});
await act(async () => {
await hook.current.removeCollaborator('u1');
});

expect(hook.current.error).toBeNull();
hook.unmount();
});

it('surfaces an error when adding fails', async () => {
const hook = renderHook(() => useCollaborators('doc1'));
await waitFor(() => !hook.current.loading);

mockApi.post.mockRejectedValueOnce(new Error('boom'));
let added = true;
await act(async () => {
added = await hook.current.addCollaborator('a@b.c');
});

expect(added).toBe(false);
expect(hook.current.error).toBe('Failed to add collaborator');
hook.unmount();
});

it('surfaces an error when removing fails', async () => {
const hook = renderHook(() => useCollaborators('doc1'));
await waitFor(() => !hook.current.loading);

mockApi.delete.mockRejectedValueOnce(new Error('boom'));
await act(async () => {
await hook.current.removeCollaborator('u1');
});

expect(hook.current.error).toBe('Failed to remove collaborator');
hook.unmount();
});
});
2 changes: 2 additions & 0 deletions client/src/hooks/use-collaborators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const useCollaborators = (docId?: string) => {

const removeCollaborator = async (userId: string) => {
if (!docId) return;
setError(null);
try {
await api.delete(`/document/${docId}/collaborators/${userId}`);
setCollaborators((prev) => prev.filter((c) => c.id !== userId));
Expand All @@ -52,6 +53,7 @@ export const useCollaborators = (docId?: string) => {

const addCollaborator = async (email: string) => {
if (!docId || !email) return false;
setError(null);
try {
await api.post(`/document/${docId}/collaborators`, { email });
const res = await api.get(`/document/${docId}/collaborators`);
Expand Down
40 changes: 28 additions & 12 deletions client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,21 @@ const dirname =
? __dirname
: path.dirname(fileURLToPath(import.meta.url));

// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
// Fresh object per project: vitest mutates browser instance configs while
// registering nested projects, so sharing one literal collides.
const browserConfig = () =>
({
enabled: true,
headless: true,
provider: 'playwright',
instances: [
{
browser: 'chromium',
},
],
}) as const;

// More info: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
Expand Down Expand Up @@ -51,21 +65,23 @@ export default defineConfig({
test: {
name: 'storybook',
// One browser instance already stretches CI runners; parallel
// files alongside the server suite starves vitest's runner.
// files alongside the server suite starve vitest's runner.
fileParallelism: false,
browser: {
enabled: true,
headless: true,
provider: 'playwright',
instances: [
{
browser: 'chromium',
},
],
},
browser: browserConfig(),
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
{
// Hook-level tests run in a real browser too — stories can't
// exercise logic that needs API interactions.
extends: true,
test: {
name: 'browser-unit',
include: ['src/**/__tests__/*.test.{ts,tsx}'],
fileParallelism: false,
browser: browserConfig(),
},
},
],
},
});
Loading