128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
|
|
/**
|
||
|
|
* US-17 — UI polish: dedup buttons + alignment + icons.
|
||
|
|
* Covers AC-17.1 (single New button on EngagementsListPage)
|
||
|
|
* and AC-17.3 (UsersAdminPage Create form alignment).
|
||
|
|
*/
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
import {
|
||
|
|
adminToken,
|
||
|
|
createEngagement,
|
||
|
|
deleteEngagement,
|
||
|
|
deleteUserByUsername,
|
||
|
|
ensureUser,
|
||
|
|
login,
|
||
|
|
} from '../fixtures/api';
|
||
|
|
import { seedTokenInStorage } from '../fixtures/auth';
|
||
|
|
|
||
|
|
const REDTEAM_USER = 'us17-redteam';
|
||
|
|
const PASS = 'us17-pass-strong';
|
||
|
|
|
||
|
|
test.describe('US-17 — UI polish', () => {
|
||
|
|
let redteamToken: string;
|
||
|
|
let adminTok: string;
|
||
|
|
let engagementId: number;
|
||
|
|
|
||
|
|
test.beforeAll(async () => {
|
||
|
|
adminTok = await adminToken();
|
||
|
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||
|
|
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||
|
|
// Seed one engagement so the list is non-empty (EmptyState won't show extra "New" link)
|
||
|
|
const eng = await createEngagement(redteamToken, {
|
||
|
|
name: 'US-17 engagement',
|
||
|
|
start_date: '2026-01-01',
|
||
|
|
});
|
||
|
|
engagementId = eng.id;
|
||
|
|
});
|
||
|
|
|
||
|
|
test.afterAll(async () => {
|
||
|
|
try {
|
||
|
|
const tok = await adminToken();
|
||
|
|
await deleteEngagement(tok, engagementId);
|
||
|
|
await deleteUserByUsername(tok, REDTEAM_USER);
|
||
|
|
} catch {
|
||
|
|
/* noop */
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('AC-17.1 — EngagementsListPage has exactly one "New" CTA button', async ({
|
||
|
|
page,
|
||
|
|
context,
|
||
|
|
}) => {
|
||
|
|
await seedTokenInStorage(context, redteamToken);
|
||
|
|
await page.goto('/engagements');
|
||
|
|
// Wait for list to load so EmptyState doesn't briefly appear
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Sprint 4: single "New" button (+ icon). Old "Create engagement" duplicate removed.
|
||
|
|
const newButtons = page.getByRole('link', { name: /new/i });
|
||
|
|
// Should have at least one
|
||
|
|
await expect(newButtons.first()).toBeVisible();
|
||
|
|
|
||
|
|
// Count all buttons/links that say "new engagement" or "create engagement"
|
||
|
|
const newEngagementLinks = await page.getByRole('link', { name: /new/i }).count();
|
||
|
|
const createEngagementLinks = await page.getByRole('link', { name: /create engagement/i }).count();
|
||
|
|
const createButtons = await page.getByRole('button', { name: /create engagement/i }).count();
|
||
|
|
|
||
|
|
// Exactly one "New" CTA — zero "Create engagement" duplicates
|
||
|
|
expect(newEngagementLinks).toBe(1);
|
||
|
|
expect(createEngagementLinks).toBe(0);
|
||
|
|
expect(createButtons).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('AC-17.1 — "New" button navigates to engagement creation form', async ({
|
||
|
|
page,
|
||
|
|
context,
|
||
|
|
}) => {
|
||
|
|
await seedTokenInStorage(context, redteamToken);
|
||
|
|
await page.goto('/engagements');
|
||
|
|
|
||
|
|
await page.getByRole('link', { name: /new/i }).first().click();
|
||
|
|
await expect(page).toHaveURL(/\/engagements\/new/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('AC-17.3 — UsersAdminPage Create account form: inputs and button aligned', async ({
|
||
|
|
page,
|
||
|
|
context,
|
||
|
|
}) => {
|
||
|
|
// UsersAdminPage is admin-only
|
||
|
|
await seedTokenInStorage(context, adminTok);
|
||
|
|
await page.goto('/admin/users');
|
||
|
|
|
||
|
|
// The form should be visible. Sprint 4: inputs use id="new-username" / id="new-password"
|
||
|
|
const usernameInput = page.locator('#new-username').first();
|
||
|
|
const passwordInput = page.locator('#new-password').first();
|
||
|
|
const createBtn = page.getByRole('button', { name: /^create$/i }).first();
|
||
|
|
|
||
|
|
await expect(usernameInput).toBeVisible();
|
||
|
|
await expect(passwordInput).toBeVisible();
|
||
|
|
await expect(createBtn).toBeVisible();
|
||
|
|
|
||
|
|
// Alignment check via boundingBox: the 4-column grid layout puts username, password,
|
||
|
|
// role, and button in the same row. All elements must be on the same vertical plane
|
||
|
|
// (same y/height) and within the viewport.
|
||
|
|
const usernameBox = await usernameInput.boundingBox();
|
||
|
|
const passwordBox = await passwordInput.boundingBox();
|
||
|
|
const btnBox = await createBtn.boundingBox();
|
||
|
|
|
||
|
|
expect(usernameBox).toBeTruthy();
|
||
|
|
expect(passwordBox).toBeTruthy();
|
||
|
|
expect(btnBox).toBeTruthy();
|
||
|
|
|
||
|
|
// All inputs are in separate columns — their y-positions (vertical alignment) should
|
||
|
|
// be within one element-height of each other (same grid row).
|
||
|
|
const yDiff = Math.abs(usernameBox!.y - passwordBox!.y);
|
||
|
|
expect(yDiff).toBeLessThanOrEqual(usernameBox!.height + 4);
|
||
|
|
|
||
|
|
// All elements should be visible within the viewport (not overflowing off-screen)
|
||
|
|
const viewportWidth = page.viewportSize()!.width;
|
||
|
|
expect(usernameBox!.x + usernameBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||
|
|
expect(passwordBox!.x + passwordBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||
|
|
expect(btnBox!.x + btnBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||
|
|
|
||
|
|
// Username comes before password horizontally (left-to-right grid order)
|
||
|
|
expect(usernameBox!.x).toBeLessThan(passwordBox!.x);
|
||
|
|
// Button is positioned after the inputs (rightmost in the grid)
|
||
|
|
expect(btnBox!.x).toBeGreaterThan(passwordBox!.x);
|
||
|
|
});
|
||
|
|
});
|