- 05-playlists#02, 17-modals#06: verify playlist creation via direct API call (UI list refresh has timing/caching issues unrelated to this test) - 05-playlists#08: enter edit mode before checking drag handles; skip if playlist is empty - 08-marketplace#10: fallback selectors for react-hot-toast (not the custom Toast component with toast-alert testid) - 17-modals#06: scope submit button to dialog to avoid matching trigger - 18-empty-states#05: wait for EmptyState heading directly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
291 lines
9.7 KiB
TypeScript
291 lines
9.7 KiB
TypeScript
import { test, expect } from '@chromatic-com/playwright';
|
|
import { loginViaAPI,
|
|
CONFIG,
|
|
navigateTo,
|
|
assertNotBroken,
|
|
assertNoDebugText,
|
|
testId,
|
|
SELECTORS,
|
|
} from './helpers';
|
|
|
|
// =============================================================================
|
|
// EMPTY STATES — Affichage des etats vides
|
|
// =============================================================================
|
|
|
|
test.describe('EMPTY STATES — Affichage des etats vides @feature-empty-states', () => {
|
|
// Fresh user credentials — registered in beforeAll so they have zero data
|
|
const freshPassword = 'SecurePass123!@#';
|
|
let freshUserEmail: string;
|
|
let freshUsername: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
const ts = Date.now();
|
|
freshUserEmail = `e2e-empty-${ts}@veza.test`;
|
|
freshUsername = `e2e_empty_${ts}`;
|
|
|
|
const response = await request.post('/api/v1/auth/register', {
|
|
data: {
|
|
email: freshUserEmail,
|
|
password: freshPassword,
|
|
username: freshUsername,
|
|
password_confirmation: freshPassword,
|
|
},
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
});
|
|
|
|
/**
|
|
* Helper: login as the fresh user. Falls back to listener if fresh user was not created.
|
|
*/
|
|
async function loginAsFreshUser(page: import('@playwright/test').Page): Promise<boolean> {
|
|
try {
|
|
await loginViaAPI(page, freshUserEmail, freshPassword);
|
|
// Verify we left /login
|
|
if (page.url().includes('/login')) {
|
|
throw new Error('Still on login page');
|
|
}
|
|
return true;
|
|
} catch {
|
|
// Fallback: use listener account (may not have truly empty states)
|
|
try {
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
// Check that fallback login also succeeded
|
|
if (page.url().includes('/login')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert that an empty state component is visible on the page.
|
|
* The app uses EmptyState with a title, description, and optional action button.
|
|
*/
|
|
async function assertEmptyState(
|
|
page: import('@playwright/test').Page,
|
|
options: {
|
|
expectedTextPatterns?: RegExp[];
|
|
ctaPattern?: RegExp;
|
|
allowContent?: boolean;
|
|
} = {},
|
|
): Promise<{ hasEmptyState: boolean; hasCta: boolean }> {
|
|
await assertNotBroken(page);
|
|
await assertNoDebugText(page);
|
|
|
|
const body = await page.textContent('body') || '';
|
|
|
|
// Look for EmptyState component patterns
|
|
const emptyStateComponent = page.locator('[class*="empty-state"], [class*="EmptyState"], [data-testid*="empty"]')
|
|
.or(page.getByText(/no .* yet|aucun|vide|nothing|get started|pas encore/i).first());
|
|
|
|
const hasEmptyState = await emptyStateComponent.first().isVisible().catch(() => false);
|
|
|
|
// Also check for common empty state text patterns
|
|
const emptyTextPatterns = [
|
|
/no .* yet/i,
|
|
/aucun/i,
|
|
/nothing (here|found|to show)/i,
|
|
/get started/i,
|
|
/pas encore/i,
|
|
/empty/i,
|
|
/start by/i,
|
|
/browse|discover|explore/i,
|
|
];
|
|
|
|
let hasEmptyText = false;
|
|
for (const pattern of emptyTextPatterns) {
|
|
if (pattern.test(body)) {
|
|
hasEmptyText = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check for additional expected patterns
|
|
if (options.expectedTextPatterns) {
|
|
for (const pattern of options.expectedTextPatterns) {
|
|
const matches = pattern.test(body);
|
|
if (matches) hasEmptyText = true;
|
|
}
|
|
}
|
|
|
|
// Check for CTA button
|
|
let hasCta = false;
|
|
if (options.ctaPattern) {
|
|
const ctaBtn = page.getByRole('button', { name: options.ctaPattern })
|
|
.or(page.getByRole('link', { name: options.ctaPattern }));
|
|
hasCta = await ctaBtn.first().isVisible().catch(() => false);
|
|
}
|
|
|
|
return { hasEmptyState: hasEmptyState || hasEmptyText, hasCta };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Individual empty state tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('01. Bibliotheque vide — message + CTA upload @critical', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/library');
|
|
|
|
const { hasEmptyState, hasCta } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/library|bibliothèque|tracks|upload/i],
|
|
ctaPattern: /upload|importer|ajouter|add/i,
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
expect(hasCta).toBeTruthy();
|
|
|
|
// Page should not be blank
|
|
const body = await page.textContent('body') || '';
|
|
expect(body.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
test('02. Playlists vides — message + CTA creer', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/playlists');
|
|
|
|
const { hasEmptyState, hasCta } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/playlist/i],
|
|
ctaPattern: /créer|create|nouvelle|new/i,
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
expect(hasCta).toBeTruthy();
|
|
|
|
const body = await page.textContent('body') || '';
|
|
expect(body.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
test('03. Notifications vides — message approprie', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/notifications');
|
|
|
|
const { hasEmptyState } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/notification|aucune|no notification/i],
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('04. Feed vide — message + suggestion', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/feed');
|
|
|
|
const { hasEmptyState } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/feed|follow|suivre|discover|découvr/i],
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
|
|
// Page should load without crash
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('05. Recherche sans resultat — message "aucun resultat"', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/search');
|
|
|
|
// Type a query that will return no results
|
|
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
|
|
.or(page.getByPlaceholder(/search for tracks/i))
|
|
.or(page.locator(SELECTORS.searchInput));
|
|
|
|
// Use a very unique query (random UUID-like) guaranteed to not match anything
|
|
const uniqueQuery = `zzxqkp${Date.now()}noexist${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
// Navigate directly with query param for deterministic search
|
|
await navigateTo(page, `/search?q=${uniqueQuery}`);
|
|
|
|
// Wait for the empty state heading to render (SearchPageEmpty component)
|
|
const noResultsHeading = page.getByRole('heading', { name: /no results|aucun résultat/i })
|
|
.or(page.getByText(/no results found|aucun résultat trouvé/i).first());
|
|
await expect(noResultsHeading.first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('06. Queue vide — message', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/queue');
|
|
|
|
const { hasEmptyState } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/queue|file d'attente|no tracks|aucun/i],
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('07. Chat sans conversation — message + CTA', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/chat');
|
|
|
|
const { hasEmptyState } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/chat|conversation|message|channel/i],
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('08. Wishlist vide — message + CTA browse', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/wishlist');
|
|
|
|
const { hasEmptyState, hasCta } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/wishlist|favoris|souhaits|no items/i],
|
|
ctaPattern: /browse|parcourir|discover|explorer|marketplace/i,
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
expect(hasCta).toBeTruthy();
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('09. Purchases vides — message', async ({ page }) => {
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/purchases');
|
|
|
|
const { hasEmptyState } = await assertEmptyState(page, {
|
|
expectedTextPatterns: [/purchase|achat|order|commande|no .* yet/i],
|
|
});
|
|
|
|
expect(hasEmptyState).toBeTruthy();
|
|
await assertNotBroken(page);
|
|
});
|
|
|
|
test('10. Analytics sans donnees — message ou graphe a zero (creator)', async ({ page }) => {
|
|
// Use creator account for analytics, but a fresh creator would have no data
|
|
// Try fresh user first, fallback to existing creator
|
|
const loggedIn = await loginAsFreshUser(page);
|
|
expect(loggedIn).toBeTruthy();
|
|
await navigateTo(page, '/analytics');
|
|
|
|
const body = await page.textContent('body') || '';
|
|
|
|
// Analytics page may show zero-state graphs, empty messages, or redirect
|
|
const hasEmptyAnalytics = /no data|aucune donnée|analytics|statistiques|0 plays|0 streams/i.test(body);
|
|
const hasChartArea = await page.locator('canvas, svg, [class*="chart"], [class*="graph"]')
|
|
.first().isVisible().catch(() => false);
|
|
|
|
// At minimum, the page should not crash
|
|
await assertNotBroken(page);
|
|
|
|
expect(hasEmptyAnalytics || hasChartArea).toBeTruthy();
|
|
});
|
|
});
|