Add a Playwright UI test for the leave-group successor picker
Original task: Add a UI test for the leave-group successor chooser dialog
in artifacts/teaboy-os/src/pages/chat.tsx, mirroring the backend coverage
in artifacts/api-server/tests/conversations-leave.test.mjs.
What I added:
- artifacts/teaboy-os/tests/leave-group-successor.spec.mjs
A real Playwright e2e test that drives the chat UI in a browser:
* Cancel path: opens the leave dialog as the sole admin, verifies the
successor chooser is visible with the auto option pre-selected and
one option per remaining member, clicks Cancel, and asserts via DB
that the conversation membership and admin flags are unchanged.
* Chosen-successor path: re-opens the dialog, picks the *later-joined*
member (so the choice differs from the auto pick), confirms the
leave, asserts the POST /conversations/:id/leave call returns 200,
and verifies via DB that the leaver was removed and the explicitly
chosen member became the new admin.
Both tests seed their own users + group via SQL and clean up after
themselves so they can run repeatedly against the live dev DB.
- artifacts/teaboy-os/playwright.config.mjs
Minimal Playwright config: testDir=tests, *.spec.mjs match, headless,
baseURL defaults to http://localhost:80 (the workspace proxy), and
is overridable via TEST_WEB_BASE.
- artifacts/teaboy-os/package.json
Added @playwright/test and pg as devDependencies and a `test:e2e`
script: `playwright test --config playwright.config.mjs`.
- .gitignore
Ignored Playwright's test-results/ and playwright-report/ output dirs.
How to run:
pnpm --filter @workspace/teaboy-os exec playwright install chromium
DATABASE_URL=... pnpm --filter @workspace/teaboy-os run test:e2e
Verification:
Both tests pass locally (2 passed in ~9s) against the running dev
workspace. Required system libraries for headless Chromium were
installed via the workspace's system-deps mechanism (glib, nss, nspr,
atk, cups, dbus, libdrm, libxkbcommon, libgbm, alsa-lib, pango, cairo,
and the relevant xorg libs), so `playwright test` works out of the
box on this environment.
No application source files were modified.
Replit-Task-Id: d2f21eab-498e-4cc0-a913-6035b714b3da
This commit is contained in:
@@ -7,10 +7,12 @@
|
||||
"dev": "vite --config vite.config.ts --host 0.0.0.0",
|
||||
"build": "vite build --config vite.config.ts",
|
||||
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test:e2e": "playwright test --config playwright.config.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
||||
@@ -59,6 +61,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.20.0",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: /.*\.spec\.mjs$/,
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env.TEST_WEB_BASE ?? "http://localhost:80",
|
||||
trace: "off",
|
||||
screenshot: "off",
|
||||
video: "off",
|
||||
headless: true,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,245 @@
|
||||
// UI test for the leave-group successor picker dialog in chat.tsx.
|
||||
//
|
||||
// Mirrors the backend coverage in
|
||||
// artifacts/api-server/tests/conversations-leave.test.mjs but at the UI layer:
|
||||
// it drives the actual dialog rendered by artifacts/teaboy-os/src/pages/chat.tsx
|
||||
// in a real browser, including:
|
||||
// - The cancel path (close the dialog -> nothing changes).
|
||||
// - The chosen-successor path (pick a specific member -> they become admin).
|
||||
//
|
||||
// Run with:
|
||||
// DATABASE_URL=... pnpm --filter @workspace/teaboy-os exec playwright test
|
||||
//
|
||||
// Browser binaries can be installed once with:
|
||||
// pnpm --filter @workspace/teaboy-os exec playwright install chromium
|
||||
//
|
||||
// The test seeds its own users + group via the database and cleans up after
|
||||
// itself, so it can run against the live dev environment without polluting it.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run the leave-group UI test");
|
||||
}
|
||||
|
||||
// Same convention as artifacts/api-server/tests/conversations-leave.test.mjs:
|
||||
// a precomputed bcrypt hash of the literal password "TestPass123!".
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdConversationIds = [];
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix) {
|
||||
const username = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username, displayName: prefix };
|
||||
}
|
||||
|
||||
async function createGroup(creatorId, members) {
|
||||
const groupName = `Successor Picker Test ${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO conversations (name_en, is_group, created_by)
|
||||
VALUES ($1, true, $2) RETURNING id`,
|
||||
[groupName, creatorId],
|
||||
);
|
||||
const conversationId = rows[0].id;
|
||||
createdConversationIds.push(conversationId);
|
||||
|
||||
// Insert participants in deterministic joined_at order so that the auto
|
||||
// promotion would pick the *first* non-admin member. The test then verifies
|
||||
// that an explicit successor choice can override that default.
|
||||
let offsetMs = 0;
|
||||
for (const m of members) {
|
||||
await pool.query(
|
||||
`INSERT INTO conversation_participants
|
||||
(conversation_id, user_id, is_admin, joined_at)
|
||||
VALUES ($1, $2, $3, NOW() - ($4 || ' milliseconds')::interval)`,
|
||||
[conversationId, m.userId, !!m.isAdmin, 10000 - offsetMs],
|
||||
);
|
||||
offsetMs += 1000;
|
||||
}
|
||||
return { conversationId, groupName };
|
||||
}
|
||||
|
||||
async function getParticipants(conversationId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT user_id AS "userId", is_admin AS "isAdmin"
|
||||
FROM conversation_participants
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY joined_at ASC`,
|
||||
[conversationId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdConversationIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM messages WHERE conversation_id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversation_participants WHERE conversation_id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM conversations WHERE id = ANY($1::int[])`,
|
||||
[createdConversationIds],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function loginViaUi(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function openLeaveDialog(page, groupName) {
|
||||
await page.goto("/chat");
|
||||
// Wait for the conversation list to load and click the seeded group.
|
||||
await page.getByRole("button", { name: new RegExp(groupName) }).first().click();
|
||||
// Open the actions sheet, then "Leave group".
|
||||
await page.locator('[data-testid="button-chat-actions"]').click();
|
||||
await expect(page.locator('[data-testid="dialog-chat-actions"]')).toBeVisible();
|
||||
await page.locator('[data-testid="button-leave-group"]').click();
|
||||
await expect(page.locator('[data-testid="dialog-confirm-leave"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="successor-chooser"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Leave group: successor picker dialog", () => {
|
||||
test("cancel path leaves the group and admin assignments unchanged", async ({
|
||||
page,
|
||||
}) => {
|
||||
const admin = await createUser("leave_ui_cancel_admin");
|
||||
const m1 = await createUser("leave_ui_cancel_m1");
|
||||
const m2 = await createUser("leave_ui_cancel_m2");
|
||||
const { conversationId, groupName } = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
|
||||
await loginViaUi(page, admin.username, TEST_PASSWORD);
|
||||
await openLeaveDialog(page, groupName);
|
||||
|
||||
// Auto option is selected by default; both successor options are listed.
|
||||
await expect(
|
||||
page.locator('[data-testid="successor-option-auto"] input[type="radio"]'),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.locator(`[data-testid="successor-option-${m1.id}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`[data-testid="successor-option-${m2.id}"]`),
|
||||
).toBeVisible();
|
||||
|
||||
// Click the Cancel button inside the leave dialog.
|
||||
await page
|
||||
.locator('[data-testid="dialog-confirm-leave"] button')
|
||||
.filter({ hasText: /^(Cancel|إلغاء)$/ })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="dialog-confirm-leave"]'),
|
||||
).toBeHidden();
|
||||
|
||||
// Membership and admin flags must be unchanged after a cancel.
|
||||
const parts = await getParticipants(conversationId);
|
||||
expect(parts).toHaveLength(3);
|
||||
const adminRow = parts.find((p) => p.userId === admin.id);
|
||||
const m1Row = parts.find((p) => p.userId === m1.id);
|
||||
const m2Row = parts.find((p) => p.userId === m2.id);
|
||||
expect(adminRow?.isAdmin).toBe(true);
|
||||
expect(m1Row?.isAdmin).toBe(false);
|
||||
expect(m2Row?.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
test("picking a specific successor promotes that member, not the auto pick", async ({
|
||||
page,
|
||||
}) => {
|
||||
const admin = await createUser("leave_ui_pick_admin");
|
||||
const m1 = await createUser("leave_ui_pick_m1"); // would be the auto pick
|
||||
const m2 = await createUser("leave_ui_pick_m2"); // explicitly chosen
|
||||
const { conversationId, groupName } = await createGroup(admin.id, [
|
||||
{ userId: admin.id, isAdmin: true },
|
||||
{ userId: m1.id, isAdmin: false },
|
||||
{ userId: m2.id, isAdmin: false },
|
||||
]);
|
||||
|
||||
await loginViaUi(page, admin.username, TEST_PASSWORD);
|
||||
await openLeaveDialog(page, groupName);
|
||||
|
||||
// Pick the later-joined member (m2) as the successor.
|
||||
await page
|
||||
.locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`)
|
||||
.check();
|
||||
await expect(
|
||||
page.locator(`[data-testid="successor-option-${m2.id}"] input[type="radio"]`),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.locator('[data-testid="successor-option-auto"] input[type="radio"]'),
|
||||
).not.toBeChecked();
|
||||
|
||||
// Confirm the leave.
|
||||
const leavePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/conversations/${conversationId}/leave`) &&
|
||||
resp.request().method() === "POST",
|
||||
);
|
||||
await page.locator('[data-testid="button-confirm-leave"]').click();
|
||||
const leaveResp = await leavePromise;
|
||||
expect(leaveResp.status()).toBe(200);
|
||||
|
||||
// The dialog closes and the user is returned to the conversation list.
|
||||
await expect(
|
||||
page.locator('[data-testid="dialog-confirm-leave"]'),
|
||||
).toBeHidden();
|
||||
|
||||
// The chosen successor — not the auto pick — must be the new admin.
|
||||
const parts = await getParticipants(conversationId);
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts.find((p) => p.userId === admin.id)).toBeUndefined();
|
||||
const m1Row = parts.find((p) => p.userId === m1.id);
|
||||
const m2Row = parts.find((p) => p.userId === m2.id);
|
||||
expect(m1Row?.isAdmin).toBe(false);
|
||||
expect(m2Row?.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
Generated
+41
@@ -374,6 +374,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.10.0
|
||||
version: 3.10.0(react-hook-form@7.71.2(react@19.1.0))
|
||||
'@playwright/test':
|
||||
specifier: ^1.59.1
|
||||
version: 1.59.1
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -518,6 +521,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -1348,6 +1354,11 @@ packages:
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -3180,6 +3191,11 @@ packages:
|
||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -3808,6 +3824,16 @@ packages:
|
||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
||||
hasBin: true
|
||||
|
||||
playwright-core@1.59.1:
|
||||
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.59.1:
|
||||
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5062,6 +5088,10 @@ snapshots:
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -6896,6 +6926,9 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -7514,6 +7547,14 @@ snapshots:
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 3.1.0
|
||||
|
||||
playwright-core@1.59.1: {}
|
||||
|
||||
playwright@1.59.1:
|
||||
dependencies:
|
||||
playwright-core: 1.59.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{pkgs}: {
|
||||
deps = [
|
||||
pkgs.expat
|
||||
pkgs.xorg.libxcb
|
||||
pkgs.xorg.libXrandr
|
||||
pkgs.xorg.libXfixes
|
||||
pkgs.xorg.libXext
|
||||
pkgs.xorg.libXdamage
|
||||
pkgs.xorg.libXcomposite
|
||||
pkgs.xorg.libX11
|
||||
pkgs.cairo
|
||||
pkgs.pango
|
||||
pkgs.alsa-lib
|
||||
pkgs.libgbm
|
||||
pkgs.libxkbcommon
|
||||
pkgs.libdrm
|
||||
pkgs.dbus
|
||||
pkgs.cups
|
||||
pkgs.at-spi2-atk
|
||||
pkgs.atk
|
||||
pkgs.nspr
|
||||
pkgs.nss
|
||||
pkgs.glib
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user