SurfSense/.cursor/skills/playwright-testing/advanced/multi-user.md

394 lines
11 KiB
Markdown
Raw Normal View History

2026-05-10 04:19:55 +05:30
# Multi-User & Collaboration Testing
## Table of Contents
1. [Multiple Browser Contexts](#multiple-browser-contexts)
2. [Real-Time Collaboration](#real-time-collaboration)
3. [Role-Based Testing](#role-based-testing)
4. [Concurrent Actions](#concurrent-actions)
5. [Chat & Messaging](#chat--messaging)
## Multiple Browser Contexts
### Two Users in Same Test
```typescript
test("two users see each other's changes", async ({ browser }) => {
// Create two isolated contexts (like two browsers)
const userAContext = await browser.newContext();
const userBContext = await browser.newContext();
const userAPage = await userAContext.newPage();
const userBPage = await userBContext.newPage();
// Both users go to the same document
await userAPage.goto("/doc/shared-123");
await userBPage.goto("/doc/shared-123");
// User A types
await userAPage.getByLabel("Content").fill("Hello from User A");
// User B should see the change
await expect(userBPage.getByText("Hello from User A")).toBeVisible();
// Cleanup
await userAContext.close();
await userBContext.close();
});
```
### Multiple Users with Auth States
```typescript
test("admin and user interaction", async ({ browser }) => {
// Load different auth states
const adminContext = await browser.newContext({
storageState: ".auth/admin.json",
});
const userContext = await browser.newContext({
storageState: ".auth/user.json",
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
// User submits request
await userPage.goto("/support");
await userPage.getByLabel("Message").fill("Need help!");
await userPage.getByRole("button", { name: "Submit" }).click();
// Admin sees and responds
await adminPage.goto("/admin/tickets");
await expect(adminPage.getByText("Need help!")).toBeVisible();
await adminPage.getByRole("button", { name: "Reply" }).click();
await adminPage.getByLabel("Response").fill("How can I help?");
await adminPage.getByRole("button", { name: "Send" }).click();
// User sees response
await expect(userPage.getByText("How can I help?")).toBeVisible();
await adminContext.close();
await userContext.close();
});
```
### Multi-User Fixture
```typescript
// fixtures/multi-user.fixture.ts
import { test as base, Browser, BrowserContext, Page } from "@playwright/test";
type UserSession = {
context: BrowserContext;
page: Page;
};
type MultiUserFixtures = {
createUser: (authState?: string) => Promise<UserSession>;
};
export const test = base.extend<MultiUserFixtures>({
createUser: async ({ browser }, use) => {
const sessions: UserSession[] = [];
await use(async (authState) => {
const context = await browser.newContext({
storageState: authState,
});
const page = await context.newPage();
sessions.push({ context, page });
return { context, page };
});
// Cleanup all sessions
for (const session of sessions) {
await session.context.close();
}
},
});
// Usage
test("3 users collaborate", async ({ createUser }) => {
const alice = await createUser(".auth/alice.json");
const bob = await createUser(".auth/bob.json");
const charlie = await createUser(".auth/charlie.json");
// All navigate to same room
await alice.page.goto("/room/123");
await bob.page.goto("/room/123");
await charlie.page.goto("/room/123");
// Test interactions...
});
```
## Real-Time Collaboration
### Collaborative Document
```typescript
test("real-time collaborative editing", async ({ browser }) => {
const user1 = await browser.newContext();
const user2 = await browser.newContext();
const page1 = await user1.newPage();
const page2 = await user2.newPage();
await page1.goto("/docs/shared");
await page2.goto("/docs/shared");
// User 1 types at the beginning
const editor1 = page1.getByRole("textbox");
await editor1.click();
await editor1.press("Home");
await editor1.type("User 1: ");
// User 2 types at the end
const editor2 = page2.getByRole("textbox");
await editor2.click();
await editor2.press("End");
await editor2.type(" - User 2");
// Both should see combined result
await expect(page1.getByRole("textbox")).toContainText("User 1:");
await expect(page1.getByRole("textbox")).toContainText("- User 2");
await expect(page2.getByRole("textbox")).toContainText("User 1:");
await expect(page2.getByRole("textbox")).toContainText("- User 2");
await user1.close();
await user2.close();
});
```
### Cursor Presence
```typescript
test("shows other user cursors", async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
// Mock to identify users
await page1.route("**/api/me", (route) =>
route.fulfill({ json: { id: "user-1", name: "Alice" } }),
);
await page2.route("**/api/me", (route) =>
route.fulfill({ json: { id: "user-2", name: "Bob" } }),
);
await page1.goto("/whiteboard/123");
await page2.goto("/whiteboard/123");
// Move cursor on page1
await page1.mouse.move(200, 200);
// Page2 should see Alice's cursor
await expect(page2.getByTestId("cursor-user-1")).toBeVisible();
await expect(page2.getByText("Alice")).toBeVisible();
await ctx1.close();
await ctx2.close();
});
```
## Role-Based Testing
### Test RBAC
```typescript
const roles = [
{ role: "admin", canDelete: true, canEdit: true, canView: true },
{ role: "editor", canDelete: false, canEdit: true, canView: true },
{ role: "viewer", canDelete: false, canEdit: false, canView: true },
];
for (const { role, canDelete, canEdit, canView } of roles) {
test(`${role} permissions`, async ({ browser }) => {
const context = await browser.newContext({
storageState: `.auth/${role}.json`,
});
const page = await context.newPage();
await page.goto("/document/123");
// Check view permission
if (canView) {
await expect(page.getByTestId("content")).toBeVisible();
} else {
await expect(page.getByText("Access denied")).toBeVisible();
}
// Check edit permission
const editButton = page.getByRole("button", { name: "Edit" });
if (canEdit) {
await expect(editButton).toBeEnabled();
} else {
await expect(editButton).toBeDisabled();
}
// Check delete permission
const deleteButton = page.getByRole("button", { name: "Delete" });
if (canDelete) {
await expect(deleteButton).toBeVisible();
} else {
await expect(deleteButton).toBeHidden();
}
await context.close();
});
}
```
### Permission Escalation Test
```typescript
test("cannot access admin routes as user", async ({ browser }) => {
const userContext = await browser.newContext({
storageState: ".auth/user.json",
});
const page = await userContext.newPage();
// Try to access admin page directly
await page.goto("/admin/users");
// Should redirect or show error
await expect(page).not.toHaveURL("/admin/users");
await expect(page.getByText("Access denied")).toBeVisible();
await userContext.close();
});
```
## Concurrent Actions
### Race Condition Testing
```typescript
test("handles concurrent edits", async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
await page1.goto("/item/123");
await page2.goto("/item/123");
// Both click edit at the same time
await Promise.all([
page1.getByRole("button", { name: "Edit" }).click(),
page2.getByRole("button", { name: "Edit" }).click(),
]);
// Both try to save different values
await page1.getByLabel("Name").fill("Value from User 1");
await page2.getByLabel("Name").fill("Value from User 2");
await Promise.all([
page1.getByRole("button", { name: "Save" }).click(),
page2.getByRole("button", { name: "Save" }).click(),
]);
// One should succeed, one should get conflict error
const page1HasConflict = await page1.getByText("Conflict").isVisible();
const page2HasConflict = await page2.getByText("Conflict").isVisible();
// Exactly one should have conflict
expect(page1HasConflict || page2HasConflict).toBe(true);
expect(page1HasConflict && page2HasConflict).toBe(false);
await ctx1.close();
await ctx2.close();
});
```
### Optimistic Locking Test
```typescript
test("optimistic locking prevents overwrites", async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
// Both load the same version
await page1.goto("/record/123");
await page2.goto("/record/123");
// User 1 edits and saves first
await page1.getByRole("button", { name: "Edit" }).click();
await page1.getByLabel("Value").fill("Updated by User 1");
await page1.getByRole("button", { name: "Save" }).click();
await expect(page1.getByText("Saved")).toBeVisible();
// User 2 tries to save with stale version
await page2.getByRole("button", { name: "Edit" }).click();
await page2.getByLabel("Value").fill("Updated by User 2");
await page2.getByRole("button", { name: "Save" }).click();
// Should fail with version conflict
await expect(page2.getByText("Someone else modified this")).toBeVisible();
await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible();
await ctx1.close();
await ctx2.close();
});
```
## Chat & Messaging
### Real-Time Chat
```typescript
test("chat messages sync between users", async ({ browser }) => {
const aliceCtx = await browser.newContext();
const bobCtx = await browser.newContext();
const alicePage = await aliceCtx.newPage();
const bobPage = await bobCtx.newPage();
// Setup user identities
await alicePage.route("**/api/me", (r) =>
r.fulfill({ json: { name: "Alice" } }),
);
await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } }));
await alicePage.goto("/chat/room-1");
await bobPage.goto("/chat/room-1");
// Alice sends message
await alicePage.getByLabel("Message").fill("Hi Bob!");
await alicePage.getByRole("button", { name: "Send" }).click();
// Bob sees it
await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible();
// Bob replies
await bobPage.getByLabel("Message").fill("Hey Alice!");
await bobPage.getByRole("button", { name: "Send" }).click();
// Alice sees it
await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible();
await aliceCtx.close();
await bobCtx.close();
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ----------------------------- | ----------------------------- | ---------------------------- |
| Sharing context between users | State leaks, not isolated | Create separate contexts |
| Not closing contexts | Memory leak, browser overload | Always close in cleanup |
| Hardcoded timing for sync | Flaky tests | Use `expect().toBeVisible()` |
| Testing only single user | Misses collaboration bugs | Test multi-user scenarios |
## Related References
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking