mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
feat: add e2e test cursor skill
This commit is contained in:
parent
2e1b9b5582
commit
4ac994792b
45 changed files with 39848 additions and 0 deletions
477
.cursor/skills/playwright-testing/multi-user-and-collaboration.md
Executable file
477
.cursor/skills/playwright-testing/multi-user-and-collaboration.md
Executable file
|
|
@ -0,0 +1,477 @@
|
|||
# Multi-User and Collaboration Testing
|
||||
|
||||
> **When to use**: When your application involves real-time collaboration, multi-user workflows, or any scenario where two or more users interact with the same resource simultaneously -- chat apps, shared documents, multiplayer features, admin-and-user flows.
|
||||
> **Prerequisites**: [core/fixtures-and-hooks.md](fixtures-and-hooks.md), [core/configuration.md](configuration.md)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
// Two independent browser contexts in one test = two users
|
||||
const alice = await browser.newContext({ storageState: 'auth/alice.json' });
|
||||
const bob = await browser.newContext({ storageState: 'auth/bob.json' });
|
||||
const alicePage = await alice.newPage();
|
||||
const bobPage = await bob.newPage();
|
||||
|
||||
// Each operates independently — different cookies, sessions, localStorage
|
||||
await alicePage.goto('/chat/room-1');
|
||||
await bobPage.goto('/chat/room-1');
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Two Users in One Test via Browser Contexts
|
||||
|
||||
**Use when**: You need to verify that actions by one user are visible to another in real time.
|
||||
**Avoid when**: You only need to test a single user's flow. One context per test is the default.
|
||||
|
||||
Each `browser.newContext()` creates a fully isolated session -- separate cookies, localStorage, and network state. This is how you simulate two logged-in users in a single test without launching a second browser.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('alice sends a message and bob sees it', async ({ browser }) => {
|
||||
// Create two isolated contexts
|
||||
const aliceContext = await browser.newContext({ storageState: 'auth/alice.json' });
|
||||
const bobContext = await browser.newContext({ storageState: 'auth/bob.json' });
|
||||
|
||||
const alicePage = await aliceContext.newPage();
|
||||
const bobPage = await bobContext.newPage();
|
||||
|
||||
// Both navigate to the same chat room
|
||||
await alicePage.goto('/chat/general');
|
||||
await bobPage.goto('/chat/general');
|
||||
|
||||
// Alice sends a message
|
||||
await alicePage.getByRole('textbox', { name: 'Message' }).fill('Hello Bob!');
|
||||
await alicePage.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Bob sees it in real time
|
||||
await expect(bobPage.getByText('Hello Bob!')).toBeVisible();
|
||||
|
||||
// Alice also sees her own message
|
||||
await expect(alicePage.getByText('Hello Bob!')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await aliceContext.close();
|
||||
await bobContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('alice sends a message and bob sees it', async ({ browser }) => {
|
||||
const aliceContext = await browser.newContext({ storageState: 'auth/alice.json' });
|
||||
const bobContext = await browser.newContext({ storageState: 'auth/bob.json' });
|
||||
|
||||
const alicePage = await aliceContext.newPage();
|
||||
const bobPage = await bobContext.newPage();
|
||||
|
||||
await alicePage.goto('/chat/general');
|
||||
await bobPage.goto('/chat/general');
|
||||
|
||||
await alicePage.getByRole('textbox', { name: 'Message' }).fill('Hello Bob!');
|
||||
await alicePage.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await expect(bobPage.getByText('Hello Bob!')).toBeVisible();
|
||||
await expect(alicePage.getByText('Hello Bob!')).toBeVisible();
|
||||
|
||||
await aliceContext.close();
|
||||
await bobContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-User Fixture for Reusability
|
||||
|
||||
**Use when**: Multiple tests need two-user setups. Wrap context creation in a fixture to avoid boilerplate.
|
||||
**Avoid when**: Only one test needs multi-user logic.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
// fixtures.ts
|
||||
import { test as base, expect, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
type MultiUserFixtures = {
|
||||
aliceContext: BrowserContext;
|
||||
alicePage: Page;
|
||||
bobContext: BrowserContext;
|
||||
bobPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<MultiUserFixtures>({
|
||||
aliceContext: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: 'auth/alice.json' });
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
alicePage: async ({ aliceContext }, use) => {
|
||||
const page = await aliceContext.newPage();
|
||||
await use(page);
|
||||
},
|
||||
bobContext: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: 'auth/bob.json' });
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
bobPage: async ({ bobContext }, use) => {
|
||||
const page = await bobContext.newPage();
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
```
|
||||
|
||||
```typescript
|
||||
// collaboration.spec.ts
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('both users see shared document title', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/docs/shared-doc');
|
||||
await bobPage.goto('/docs/shared-doc');
|
||||
|
||||
await alicePage.getByRole('textbox', { name: 'Title' }).fill('Project Plan');
|
||||
|
||||
await expect(bobPage.getByRole('textbox', { name: 'Title' })).toHaveValue('Project Plan');
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
// fixtures.js
|
||||
const { test: base, expect } = require('@playwright/test');
|
||||
|
||||
const test = base.extend({
|
||||
aliceContext: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: 'auth/alice.json' });
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
alicePage: async ({ aliceContext }, use) => {
|
||||
const page = await aliceContext.newPage();
|
||||
await use(page);
|
||||
},
|
||||
bobContext: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: 'auth/bob.json' });
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
bobPage: async ({ bobContext }, use) => {
|
||||
const page = await bobContext.newPage();
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = { test, expect };
|
||||
```
|
||||
|
||||
### Collaborative Editing with Conflict Detection
|
||||
|
||||
**Use when**: Testing real-time collaborative editors (Google Docs-style) where both users edit the same content.
|
||||
**Avoid when**: Your app does not support concurrent editing.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('concurrent edits merge without data loss', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/docs/shared-doc');
|
||||
await bobPage.goto('/docs/shared-doc');
|
||||
|
||||
// Wait for both to be connected
|
||||
await expect(alicePage.getByTestId('connection-status')).toHaveText('Connected');
|
||||
await expect(bobPage.getByTestId('connection-status')).toHaveText('Connected');
|
||||
|
||||
// Alice types at the beginning
|
||||
const aliceEditor = alicePage.getByRole('textbox', { name: 'Editor' });
|
||||
await aliceEditor.pressSequentially('Alice was here. ');
|
||||
|
||||
// Bob types at the end (simultaneously)
|
||||
const bobEditor = bobPage.getByRole('textbox', { name: 'Editor' });
|
||||
await bobEditor.press('End');
|
||||
await bobEditor.pressSequentially('Bob was here.');
|
||||
|
||||
// Both edits should be visible to both users
|
||||
await expect(aliceEditor).toContainText('Alice was here.');
|
||||
await expect(aliceEditor).toContainText('Bob was here.');
|
||||
await expect(bobEditor).toContainText('Alice was here.');
|
||||
await expect(bobEditor).toContainText('Bob was here.');
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('./fixtures');
|
||||
|
||||
test('concurrent edits merge without data loss', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/docs/shared-doc');
|
||||
await bobPage.goto('/docs/shared-doc');
|
||||
|
||||
await expect(alicePage.getByTestId('connection-status')).toHaveText('Connected');
|
||||
await expect(bobPage.getByTestId('connection-status')).toHaveText('Connected');
|
||||
|
||||
const aliceEditor = alicePage.getByRole('textbox', { name: 'Editor' });
|
||||
await aliceEditor.pressSequentially('Alice was here. ');
|
||||
|
||||
const bobEditor = bobPage.getByRole('textbox', { name: 'Editor' });
|
||||
await bobEditor.press('End');
|
||||
await bobEditor.pressSequentially('Bob was here.');
|
||||
|
||||
await expect(aliceEditor).toContainText('Alice was here.');
|
||||
await expect(aliceEditor).toContainText('Bob was here.');
|
||||
await expect(bobEditor).toContainText('Alice was here.');
|
||||
await expect(bobEditor).toContainText('Bob was here.');
|
||||
});
|
||||
```
|
||||
|
||||
### Shared State Verification (Presence, Cursors, Indicators)
|
||||
|
||||
**Use when**: Verifying that one user's presence or activity is reflected in the other user's UI -- online indicators, typing indicators, cursor positions.
|
||||
**Avoid when**: Presence is not a feature of your app.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('bob sees alice typing indicator', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/chat/general');
|
||||
await bobPage.goto('/chat/general');
|
||||
|
||||
// Alice starts typing
|
||||
await alicePage.getByRole('textbox', { name: 'Message' }).pressSequentially('Hel', { delay: 100 });
|
||||
|
||||
// Bob sees the typing indicator
|
||||
await expect(bobPage.getByText('Alice is typing...')).toBeVisible();
|
||||
|
||||
// Alice stops typing — indicator disappears after debounce
|
||||
await expect(bobPage.getByText('Alice is typing...')).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('online users list updates when user joins', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/chat/general');
|
||||
|
||||
// Alice sees only herself
|
||||
await expect(alicePage.getByTestId('online-users').getByText('Alice')).toBeVisible();
|
||||
await expect(alicePage.getByTestId('online-users').getByText('Bob')).toBeHidden();
|
||||
|
||||
// Bob joins
|
||||
await bobPage.goto('/chat/general');
|
||||
|
||||
// Alice now sees Bob in the online list
|
||||
await expect(alicePage.getByTestId('online-users').getByText('Bob')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('./fixtures');
|
||||
|
||||
test('bob sees alice typing indicator', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/chat/general');
|
||||
await bobPage.goto('/chat/general');
|
||||
|
||||
await alicePage.getByRole('textbox', { name: 'Message' }).pressSequentially('Hel', { delay: 100 });
|
||||
await expect(bobPage.getByText('Alice is typing...')).toBeVisible();
|
||||
await expect(bobPage.getByText('Alice is typing...')).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('online users list updates when user joins', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/chat/general');
|
||||
await expect(alicePage.getByTestId('online-users').getByText('Alice')).toBeVisible();
|
||||
|
||||
await bobPage.goto('/chat/general');
|
||||
await expect(alicePage.getByTestId('online-users').getByText('Bob')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Race Condition Testing Between Users
|
||||
|
||||
**Use when**: You need to verify that simultaneous actions (two users clicking "buy" on the last item, or two users editing the same field) are handled correctly.
|
||||
**Avoid when**: Your app has no shared mutable resources.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('only one user can claim the last item', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/store/limited-item');
|
||||
await bobPage.goto('/store/limited-item');
|
||||
|
||||
// Both see the item is available
|
||||
await expect(alicePage.getByText('1 remaining')).toBeVisible();
|
||||
await expect(bobPage.getByText('1 remaining')).toBeVisible();
|
||||
|
||||
// Both click buy at approximately the same time
|
||||
const aliceBuy = alicePage.getByRole('button', { name: 'Buy now' }).click();
|
||||
const bobBuy = bobPage.getByRole('button', { name: 'Buy now' }).click();
|
||||
await Promise.all([aliceBuy, bobBuy]);
|
||||
|
||||
// One succeeds, one fails — verify the app handles the race
|
||||
const aliceResult = await alicePage.getByTestId('purchase-result').textContent();
|
||||
const bobResult = await bobPage.getByTestId('purchase-result').textContent();
|
||||
|
||||
const results = [aliceResult, bobResult];
|
||||
expect(results).toContain('Purchase successful');
|
||||
expect(results).toContain('Item no longer available');
|
||||
});
|
||||
|
||||
test('simultaneous form submissions do not create duplicates', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/admin/settings');
|
||||
await bobPage.goto('/admin/settings');
|
||||
|
||||
// Both change the same setting
|
||||
await alicePage.getByLabel('Company name').fill('Alice Corp');
|
||||
await bobPage.getByLabel('Company name').fill('Bob Inc');
|
||||
|
||||
// Submit simultaneously
|
||||
await Promise.all([
|
||||
alicePage.getByRole('button', { name: 'Save' }).click(),
|
||||
bobPage.getByRole('button', { name: 'Save' }).click(),
|
||||
]);
|
||||
|
||||
// Reload both pages — one value should win, no corruption
|
||||
await alicePage.reload();
|
||||
const finalValue = await alicePage.getByLabel('Company name').inputValue();
|
||||
expect(['Alice Corp', 'Bob Inc']).toContain(finalValue);
|
||||
|
||||
// The "loser" should see a conflict notification or the updated value
|
||||
await bobPage.reload();
|
||||
await expect(bobPage.getByLabel('Company name')).toHaveValue(finalValue);
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('./fixtures');
|
||||
|
||||
test('only one user can claim the last item', async ({ alicePage, bobPage }) => {
|
||||
await alicePage.goto('/store/limited-item');
|
||||
await bobPage.goto('/store/limited-item');
|
||||
|
||||
await expect(alicePage.getByText('1 remaining')).toBeVisible();
|
||||
await expect(bobPage.getByText('1 remaining')).toBeVisible();
|
||||
|
||||
const aliceBuy = alicePage.getByRole('button', { name: 'Buy now' }).click();
|
||||
const bobBuy = bobPage.getByRole('button', { name: 'Buy now' }).click();
|
||||
await Promise.all([aliceBuy, bobBuy]);
|
||||
|
||||
const aliceResult = await alicePage.getByTestId('purchase-result').textContent();
|
||||
const bobResult = await bobPage.getByTestId('purchase-result').textContent();
|
||||
|
||||
const results = [aliceResult, bobResult];
|
||||
expect(results).toContain('Purchase successful');
|
||||
expect(results).toContain('Item no longer available');
|
||||
});
|
||||
```
|
||||
|
||||
### N Users with Dynamic Context Creation
|
||||
|
||||
**Use when**: You need more than two users, or the number of users is variable (load-like collaboration tests).
|
||||
**Avoid when**: Two users suffice. Keep it simple.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect, Browser, Page } from '@playwright/test';
|
||||
|
||||
async function createUser(browser: Browser, name: string, room: string): Promise<Page> {
|
||||
const context = await browser.newContext({ storageState: `auth/${name}.json` });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`/chat/${room}`);
|
||||
return page;
|
||||
}
|
||||
|
||||
test('five users in a chat room all see each other', async ({ browser }) => {
|
||||
const names = ['alice', 'bob', 'charlie', 'diana', 'eve'];
|
||||
const pages = await Promise.all(
|
||||
names.map((name) => createUser(browser, name, 'team-room'))
|
||||
);
|
||||
|
||||
// First user sends a message
|
||||
await pages[0].getByRole('textbox', { name: 'Message' }).fill('Hello everyone!');
|
||||
await pages[0].getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// All users see the message
|
||||
for (const page of pages) {
|
||||
await expect(page.getByText('Hello everyone!')).toBeVisible();
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function createUser(browser, name, room) {
|
||||
const context = await browser.newContext({ storageState: `auth/${name}.json` });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`/chat/${room}`);
|
||||
return page;
|
||||
}
|
||||
|
||||
test('five users in a chat room all see each other', async ({ browser }) => {
|
||||
const names = ['alice', 'bob', 'charlie', 'diana', 'eve'];
|
||||
const pages = await Promise.all(
|
||||
names.map((name) => createUser(browser, name, 'team-room'))
|
||||
);
|
||||
|
||||
await pages[0].getByRole('textbox', { name: 'Message' }).fill('Hello everyone!');
|
||||
await pages[0].getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
for (const page of pages) {
|
||||
await expect(page.getByText('Hello everyone!')).toBeVisible();
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
|---|---|---|
|
||||
| Two users interact in real time | Two `browser.newContext()` in one test | Fully isolated sessions, same test timeline |
|
||||
| Same multi-user setup across many tests | Custom fixture per user context | Eliminates boilerplate, guarantees cleanup |
|
||||
| Testing presence/typing indicators | Sequential actions, assert on other page | Order matters -- act on one, assert on the other |
|
||||
| Race condition (simultaneous clicks) | `Promise.all([action1, action2])` | Fires both as close to simultaneously as possible |
|
||||
| Admin performs action, user sees result | Two contexts with different `storageState` | Different auth roles, same browser instance |
|
||||
| 3+ users in one test | Loop with `browser.newContext()` per user | Each context is cheap; browser is shared |
|
||||
| Cross-browser multi-user (Chrome + Firefox) | Separate browser launches in `beforeAll` | Rarely needed; contexts within one browser suffice |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
|---|---|---|
|
||||
| Use one context with two pages for two users | Pages in the same context share cookies and localStorage | Use `browser.newContext()` per user |
|
||||
| Open a second browser for the second user | Wastes memory and startup time | Use a second `browser.newContext()` -- contexts are lightweight |
|
||||
| `await page.waitForTimeout(2000)` for real-time sync | Arbitrary delay; flaky in CI, slow in dev | `await expect(bobPage.getByText('msg')).toBeVisible()` |
|
||||
| Share mutable state via `let` variables between contexts | Test order and timing become implicit dependencies | Each context is independent; assert via UI |
|
||||
| Put multi-user setup in `beforeAll` | `beforeAll` cannot access `page` or `context` | Use test-scoped fixtures or inline `browser.newContext()` |
|
||||
| Forget to close extra contexts | Leaks memory and may cause port exhaustion | Always close contexts in fixture teardown or `finally` |
|
||||
| Assert on both pages without waiting | The second page may not have received the update yet | Use `expect` with auto-retry on the receiving page |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Second user sees stale data | Context created before server state was ready | Navigate the second page after the first user's action completes |
|
||||
| `storageState` file not found | Auth setup project has not run, or path is wrong | Run auth setup first via `dependencies` in `playwright.config` |
|
||||
| Both users have the same session | Reusing the same `storageState` file for both | Create distinct auth state files per user role |
|
||||
| Real-time updates never arrive on second page | WebSocket/SSE connection not established before assertion | Wait for a connection indicator before asserting: `await expect(page.getByTestId('connected')).toBeVisible()` |
|
||||
| Test is slow with many contexts | Too many contexts in a single test (10+) | Limit to 3-5 contexts per test; use API setup to reduce page interactions |
|
||||
| `Promise.all` race test always has same winner | Network/event loop makes one always faster | This is expected in testing -- assert that the app handles both outcomes, not which user wins |
|
||||
|
||||
## Related
|
||||
|
||||
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrap multi-user contexts in fixtures
|
||||
- [core/websockets-and-realtime.md](websockets-and-realtime.md) -- test the transport layer beneath collaboration features
|
||||
- [core/test-data-management.md](test-data-management.md) -- set up shared resources (rooms, documents) before multi-user tests
|
||||
- [core/configuration.md](configuration.md) -- configure `storageState` per project for different user roles
|
||||
Loading…
Add table
Add a link
Reference in a new issue