453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
import { Page } from '@playwright/test';
|
|
import { BasePage } from './BasePage';
|
|
import { Quest } from '../types';
|
|
|
|
/**
|
|
* Quests Page Object
|
|
* Handles all interactions on the /quests route
|
|
*
|
|
* Real DOM structure (QuestsPage.tsx + QuestForm.tsx + QuestList.tsx):
|
|
* Button "🗂️ Propose a Quest" — opens QuestForm overlay
|
|
* QuestForm overlay:
|
|
* #title input
|
|
* #description textarea
|
|
* #quest_type select
|
|
* #priority select
|
|
* #status select
|
|
* #location select (required)
|
|
* button "Propose Quest" / "Revise Quest"
|
|
* QuestList:
|
|
* [data-testid="quest-item"][data-quest-title="…"]
|
|
* button "✓ Complete Quest"
|
|
* button "✏️ Revise"
|
|
* button "✕ Abandon"
|
|
* Filter buttons (plain <button> elements):
|
|
* Status: "All Quests", "In Progress", "Completed", "Blocked"
|
|
* Type: "All Types", "The Journey", "The Battle", "The Fellowship", "The Ring"
|
|
* Location: per-location buttons
|
|
* QuestCompletionModal:
|
|
* h2 "⚔️ Quest Challenge"
|
|
*/
|
|
export class QuestsPage extends BasePage {
|
|
/** Title of the quest targeted by the last clickQuest() call */
|
|
private selectedQuestTitle: string | null = null;
|
|
|
|
// --- Form (inside quest-form-overlay) ---
|
|
private readonly formOverlay = () => this.page.locator('.quest-form-overlay').first();
|
|
private readonly questTitleInput = () => this.formOverlay().locator('#title').first();
|
|
private readonly questDescriptionInput = () => this.formOverlay().locator('#description').first();
|
|
private readonly questTypeSelect = () => this.formOverlay().locator('#quest_type').first();
|
|
private readonly questPrioritySelect = () => this.formOverlay().locator('#priority').first();
|
|
private readonly questStatusSelect = () => this.formOverlay().locator('#status').first();
|
|
private readonly questLocationSelect = () => this.formOverlay().locator('#location').first();
|
|
private readonly submitQuestButton = () =>
|
|
this.formOverlay().locator('button[type="submit"]').first();
|
|
|
|
// --- Quest list items ---
|
|
private readonly questItems = () => this.page.locator('[data-testid="quest-item"]');
|
|
|
|
// Returns the card element for a given quest title
|
|
private questCard(title: string) {
|
|
return this.page.locator(`[data-testid="quest-item"][data-quest-title="${title}"]`).first();
|
|
}
|
|
|
|
// --- Completion mini-game modal ---
|
|
private readonly miniGameModal = () =>
|
|
this.page.locator('h2:has-text("Quest Challenge")').locator('../../..').first();
|
|
|
|
async goto(): Promise<void> {
|
|
// Don't use page.goto() as it remounts the React app and loses session state
|
|
// Instead, use internal React Router navigation via page.click() on the nav link
|
|
const currentUrl = this.page.url();
|
|
if (currentUrl.includes('/quests')) {
|
|
// Already on quests page - navigate away and back to force component remount and data refresh
|
|
const dashboardLink = this.page.locator('a[href="/dashboard"]').first();
|
|
if (await dashboardLink.count().then(c => c > 0)) {
|
|
await dashboardLink.click();
|
|
await this.page.waitForURL('**/dashboard', { timeout: 5000 });
|
|
await this.page.waitForTimeout(500);
|
|
}
|
|
}
|
|
const questsLink = this.page.locator('a[href="/quests"]').first();
|
|
const linkExists = await questsLink.count().then(c => c > 0);
|
|
|
|
if (linkExists) {
|
|
await questsLink.click();
|
|
// Wait for navigation to complete
|
|
await this.page.waitForURL('**/quests', { timeout: 10000 });
|
|
await this.page.waitForTimeout(1000); // Wait for quest data to load
|
|
} else {
|
|
// Fallback to page.goto() if no link found
|
|
await super.goto('/quests');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for the quests page to be fully loaded
|
|
*/
|
|
async isLoaded(): Promise<boolean> {
|
|
try {
|
|
// Wait for either the "Propose a Quest" button or at least one quest item
|
|
await Promise.race([
|
|
this.page
|
|
.getByRole('button', { name: /propose a quest/i })
|
|
.waitFor({ state: 'visible', timeout: 5000 }),
|
|
this.page
|
|
.locator('[data-testid="quest-item"]')
|
|
.first()
|
|
.waitFor({ state: 'visible', timeout: 5000 }),
|
|
]);
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the quest creation form
|
|
*/
|
|
private async openCreateForm(): Promise<void> {
|
|
await this.page.getByRole('button', { name: /propose a quest/i }).first().click();
|
|
await this.formOverlay().waitFor({ state: 'visible', timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Create a new quest.
|
|
* Automatically picks the first available location and priority when not provided.
|
|
*/
|
|
async createQuest(quest: Omit<Quest, 'id'>): Promise<void> {
|
|
await this.openCreateForm();
|
|
await this.questTitleInput().fill(quest.title);
|
|
await this.questDescriptionInput().fill(quest.description);
|
|
|
|
if (quest.type) {
|
|
await this.questTypeSelect().selectOption({ label: quest.type });
|
|
}
|
|
|
|
// priority — map test values to form values, or pick first non-empty option
|
|
if (quest.priority) {
|
|
const priorityMap: Record<string, string> = {
|
|
Low: 'Standard',
|
|
Medium: 'Important',
|
|
High: 'Critical',
|
|
Critical: 'Critical',
|
|
Important: 'Important',
|
|
Standard: 'Standard',
|
|
};
|
|
const formPriority = priorityMap[quest.priority] || quest.priority;
|
|
await this.questPrioritySelect().selectOption({ label: formPriority });
|
|
} else {
|
|
// Pick first non-empty priority option
|
|
await this.questPrioritySelect().selectOption({ index: 1 });
|
|
}
|
|
|
|
// Location is required — pick first available if not specified
|
|
if (quest.location) {
|
|
await this.questLocationSelect().selectOption({ label: new RegExp(quest.location, 'i') });
|
|
} else {
|
|
await this.questLocationSelect().selectOption({ index: 1 });
|
|
}
|
|
|
|
// Dark magic checkbox
|
|
if (quest.darkMagic) {
|
|
const checkbox = this.formOverlay().locator('#is_dark_magic');
|
|
if (!(await checkbox.isChecked())) {
|
|
await checkbox.click();
|
|
}
|
|
}
|
|
|
|
await this.submitQuestButton().click();
|
|
// Wait for form to close
|
|
await this.formOverlay().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Get all quest titles currently visible in the list
|
|
*/
|
|
async getQuestTitles(): Promise<string[]> {
|
|
try {
|
|
await this.pollUntil(
|
|
async () => (await this.questItems().count()) > 0,
|
|
10000,
|
|
500,
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const items = this.questItems();
|
|
const count = await items.count();
|
|
const titles: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const attr = await items.nth(i).getAttribute('data-quest-title');
|
|
if (attr) {
|
|
titles.push(attr.trim());
|
|
} else {
|
|
const text = await items.nth(i).locator('h3').first().textContent();
|
|
if (text) titles.push(text.trim().replace(/^[^\w]*/, '').trim());
|
|
}
|
|
}
|
|
return titles;
|
|
}
|
|
|
|
/**
|
|
* Select a quest to operate on.
|
|
* Stores the title for subsequent editQuest() / deleteQuest() calls.
|
|
*/
|
|
async clickQuest(questTitle: string): Promise<void> {
|
|
this.selectedQuestTitle = questTitle;
|
|
// Scroll the card into view
|
|
try {
|
|
await this.questCard(questTitle).scrollIntoViewIfNeeded();
|
|
} catch {
|
|
// Card may not have data-quest-title yet — fall back
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter quests by status using the status-filter buttons.
|
|
* Accepts both display values ("It Is Done") and API values ("it_is_done").
|
|
*/
|
|
async filterByStatus(status: string): Promise<void> {
|
|
const labelMap: Record<string, RegExp> = {
|
|
'It Is Done': /^Completed/i,
|
|
'it_is_done': /^Completed/i,
|
|
'The Road Goes Ever On...': /^In Progress/i,
|
|
'the_road_goes_ever_on': /^In Progress/i,
|
|
'The Shadow Falls': /^Blocked/i,
|
|
'the_shadow_falls': /^Blocked/i,
|
|
'Not Yet Begun': /^All Quests/i,
|
|
'not_yet_begun': /^All Quests/i,
|
|
};
|
|
const pattern = labelMap[status] || new RegExp(status, 'i');
|
|
// Status filters are expanded by default, so just click
|
|
await this.page.getByRole('button', { name: pattern }).first().click();
|
|
await this.page.waitForTimeout(300);
|
|
}
|
|
|
|
/**
|
|
* Filter quests by type using the type-filter buttons.
|
|
* First expands the Quest Type category if it's collapsed.
|
|
*/
|
|
async filterByType(type: string): Promise<void> {
|
|
// Expand Type filter category if not already expanded
|
|
try {
|
|
const typeHeader = this.page.getByRole('button', { name: /^Quest Type$/i }).first();
|
|
// Scroll into view first
|
|
await typeHeader.scrollIntoViewIfNeeded();
|
|
const isExpanded = await typeHeader.getAttribute('aria-expanded');
|
|
if (isExpanded === 'false') {
|
|
await typeHeader.click();
|
|
await this.page.waitForTimeout(1500); // Give time for filters to appear and expand
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not find/expand Quest Type header:', e);
|
|
}
|
|
|
|
// Wait a bit to ensure DOM is updated
|
|
await this.page.waitForTimeout(500);
|
|
|
|
// Click the type filter button
|
|
try {
|
|
// Use getByText which is more reliable than has-text
|
|
const button = this.page.getByText(type, { exact: false }).filter({ role: 'button' }).first();
|
|
await button.scrollIntoViewIfNeeded();
|
|
await button.click({ timeout: 15000 });
|
|
await this.page.waitForTimeout(300);
|
|
} catch (e) {
|
|
throw new Error(`Could not click filter button for type "${type}". Error: ${e}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter quests by location using the location-filter buttons.
|
|
* First expands the Location category if it's collapsed.
|
|
*/
|
|
async filterByLocation(locationName: string): Promise<void> {
|
|
// Expand Location filter category if not already expanded
|
|
try {
|
|
const locationHeader = this.page.getByRole('button', { name: /^Location$/i }).first();
|
|
// Scroll into view first
|
|
await locationHeader.scrollIntoViewIfNeeded();
|
|
const isExpanded = await locationHeader.getAttribute('aria-expanded');
|
|
if (isExpanded === 'false') {
|
|
await locationHeader.click();
|
|
await this.page.waitForTimeout(1500); // Give time for filters to appear and expand
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not find/expand Location header:', e);
|
|
}
|
|
|
|
// Wait a bit to ensure DOM is updated
|
|
await this.page.waitForTimeout(500);
|
|
|
|
// Click the location filter button
|
|
try {
|
|
// Use getByText which is more reliable
|
|
const button = this.page.getByText(locationName, { exact: false }).filter({ role: 'button' }).first();
|
|
await button.scrollIntoViewIfNeeded();
|
|
await button.click({ timeout: 15000 });
|
|
await this.page.waitForTimeout(300);
|
|
} catch (e) {
|
|
throw new Error(`Could not click filter button for location "${locationName}". Error: ${e}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the edit form for the selected quest and apply updates.
|
|
* Must be called after clickQuest().
|
|
*/
|
|
async editQuest(updates: Partial<Quest>): Promise<void> {
|
|
const title = this.selectedQuestTitle;
|
|
if (!title) throw new Error('No quest selected. Call clickQuest() first.');
|
|
|
|
// Click the "✏️ Revise" button on the quest card
|
|
await this.questCard(title).getByRole('button', { name: /revise/i }).click();
|
|
await this.formOverlay().waitFor({ state: 'visible', timeout: 10000 });
|
|
|
|
if (updates.title) {
|
|
await this.questTitleInput().clear();
|
|
await this.questTitleInput().fill(updates.title);
|
|
}
|
|
if (updates.description) {
|
|
await this.questDescriptionInput().clear();
|
|
await this.questDescriptionInput().fill(updates.description);
|
|
}
|
|
if (updates.status) {
|
|
// Map display status to form option label
|
|
const statusMap: Record<string, string> = {
|
|
'Not Yet Begun': 'Not Yet Begun',
|
|
'not_yet_begun': 'Not Yet Begun',
|
|
'The Road Goes Ever On...': 'The Road Goes Ever On...',
|
|
'the_road_goes_ever_on': 'The Road Goes Ever On...',
|
|
'It Is Done': 'It Is Done',
|
|
'it_is_done': 'It Is Done',
|
|
'The Shadow Falls': 'The Shadow Falls',
|
|
'the_shadow_falls': 'The Shadow Falls',
|
|
};
|
|
const label = statusMap[updates.status] || updates.status;
|
|
await this.questStatusSelect().selectOption({ label });
|
|
}
|
|
|
|
await this.submitQuestButton().click();
|
|
await this.formOverlay().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Delete the currently selected quest.
|
|
* Must be called after clickQuest().
|
|
* Handles the native window.confirm dialog automatically.
|
|
*/
|
|
async deleteQuest(): Promise<void> {
|
|
const title = this.selectedQuestTitle;
|
|
if (!title) throw new Error('No quest selected. Call clickQuest() first.');
|
|
|
|
// Accept the confirm dialog before the click triggers it
|
|
this.page.once('dialog', (dialog) => dialog.accept());
|
|
await this.questCard(title).getByRole('button', { name: /abandon/i }).click();
|
|
|
|
// Wait for the card to disappear
|
|
await this.questCard(title).waitFor({ state: 'detached', timeout: 10000 }).catch(() => {});
|
|
this.selectedQuestTitle = null;
|
|
}
|
|
|
|
/**
|
|
* Trigger quest completion (opens mini-game modal).
|
|
* Must be called after clickQuest().
|
|
*/
|
|
async completeQuest(): Promise<void> {
|
|
const title = this.selectedQuestTitle;
|
|
if (!title) throw new Error('No quest selected. Call clickQuest() first.');
|
|
|
|
await this.questCard(title).getByRole('button', { name: /complete quest/i }).click();
|
|
await this.page
|
|
.locator('h2:has-text("Quest Challenge")')
|
|
.waitFor({ state: 'visible', timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Check if the mini-game modal is visible
|
|
*/
|
|
async isMiniGameModalVisible(): Promise<boolean> {
|
|
return this.page
|
|
.locator('h2:has-text("Quest Challenge")')
|
|
.isVisible({ timeout: 5000 })
|
|
.catch(() => false);
|
|
}
|
|
|
|
/**
|
|
* Wait for the mini-game modal to close (hidden or detached)
|
|
*/
|
|
async waitForMiniGameModalToClose(): Promise<void> {
|
|
await this.page
|
|
.locator('h2:has-text("Quest Challenge")')
|
|
.waitFor({ state: 'hidden', timeout: 10000 })
|
|
.catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Get the mini-game type from the visible mini-game modal
|
|
*/
|
|
async getMiniGameType(): Promise<string> {
|
|
// Each mini-game renders a recognisable heading
|
|
const candidates = ['Trivia', 'Memory', 'Reaction', 'Find the Ring'];
|
|
|
|
// First try: look for game name as heading text within the modal
|
|
for (const game of candidates) {
|
|
try {
|
|
const heading = this.page.locator(`h2:has-text("${game}"), h3:has-text("${game}")`);
|
|
const count = await heading.count();
|
|
if (count > 0) return game;
|
|
} catch {
|
|
// Continue to next candidate
|
|
}
|
|
}
|
|
|
|
// Second try: general text search
|
|
for (const game of candidates) {
|
|
try {
|
|
const element = this.page.locator(`text/${game}/i`);
|
|
const visible = await element.isVisible({ timeout: 2000 }).catch(() => false);
|
|
if (visible) return game;
|
|
} catch {
|
|
// Continue to next candidate
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Fail the mini-game by clicking the hidden force-fail trigger button.
|
|
* Uses page.evaluate() to bypass viewport restrictions for the off-screen button.
|
|
*/
|
|
async failMiniGame(): Promise<void> {
|
|
// Use page.evaluate to click the hidden test button (bypasses viewport check)
|
|
await this.page.evaluate(() => {
|
|
const btn = document.querySelector('[data-testid="force-game-fail"]') as HTMLElement | null;
|
|
if (btn) btn.click();
|
|
});
|
|
// Wait for the "Try Again" button to appear (fail state shows retry button)
|
|
await this.page.locator('button:has-text("Try Again")').waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Win the mini-game by clicking the hidden force-win trigger button.
|
|
* Then click "Claim Reward" to close the modal and mark quest complete.
|
|
*/
|
|
async winMiniGame(): Promise<void> {
|
|
// Use page.evaluate to click the hidden test button (bypasses viewport check)
|
|
await this.page.evaluate(() => {
|
|
const btn = document.querySelector('[data-testid="force-game-win"]') as HTMLElement | null;
|
|
if (btn) btn.click();
|
|
});
|
|
// Wait for the Claim Reward button to appear (win state)
|
|
const claimButton = this.page.getByRole('button', { name: /Claim Reward/i }).first();
|
|
await claimButton.waitFor({ state: 'visible', timeout: 10000 });
|
|
await claimButton.click();
|
|
// Wait for quest completion API call and modal to close
|
|
await this.page.locator('h2:has-text("Quest Challenge")').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
|
|
}
|
|
}
|
|
|