fix: refactor enchanter and fabricator e2e tests
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s

Enchanter test:
- Use data-testid selectors for debug buttons
- Add waitForBridge pattern
- Switch baseURL to localhost:3000

Fabricator test:
- Use data-testid selectors for all debug buttons (attunements, elements, disciplines, materials)
- Activate discipline via toggle button before adding XP
- Unlock recipes via discipline XP + store unlockRecipes
- Replace waitForTimeout with runTicks for crafting (instant tick-based waiting)
- Add ticksForHours helper for deterministic craft completion
- Verify each craft completed via store check instead of currentAction polling
- Remove direct store manipulation for attunement/element unlock (use debug UI buttons)
This commit is contained in:
2026-06-02 16:39:33 +02:00
parent e95a378731
commit 3383aedd2f
7 changed files with 109 additions and 84 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies
Generated: 2026-06-02T13:46:41.866Z
Generated: 2026-06-02T14:00:41.812Z
No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-02T13:46:40.091Z",
"generated": "2026-06-02T14:00:40.018Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
+18 -10
View File
@@ -1,7 +1,7 @@
import { test, expect, type Page } from '@playwright/test';
test.use({
baseURL: 'https://manaloop.tailf367e3.ts.net/',
baseURL: 'http://localhost:3000/',
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -15,6 +15,7 @@ async function startFreshGame(page: Page) {
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForLoadState('networkidle');
await waitForMs(page, 3000);
}
async function clickTab(page: Page, label: string) {
@@ -23,12 +24,21 @@ async function clickTab(page: Page, label: string) {
await waitForMs(page, 400);
}
async function waitForBridge(page: Page) {
for (let attempt = 0; attempt < 30; attempt++) {
const ready = await page.evaluate(() => !!(window as any).__TEST__);
if (ready) return;
await waitForMs(page, 1000);
}
throw new Error('Debug bridge (window.__TEST__) not available after 30s');
}
// ─── Test ────────────────────────────────────────────────────────────────────
test.describe('Enchanter Happy-Path: Design → Prepare → Apply on Starter Gear', () => {
test('enchant Civilian Shirt: full UI workflow (Design → Prepare → Apply)', async ({ page }) => {
test.setTimeout(240000);
test.setTimeout(240_000);
const errors: string[] = [];
page.on('console', (msg) => {
@@ -37,18 +47,16 @@ test.describe('Enchanter Happy-Path: Design → Prepare → Apply on Starter Gea
// ── 1. Start fresh game ───────────────────────────────────────────────────
await startFreshGame(page);
await waitForMs(page, 1000);
await waitForBridge(page);
// ── 2. Pre-unlock effects + add raw mana ──────────────────────────────────
// Use Debug UI to add raw mana (for preparation cost).
// ── 2. Add raw mana via Debug UI ──────────────────────────────────────────
await clickTab(page, 'debug');
await waitForMs(page, 500);
const add10KBtn = page.getByRole('button', { name: /\+10k/i }).first();
if (await add10KBtn.isVisible({ timeout: 3000 })) {
await add10KBtn.click();
await waitForMs(page, 200);
}
const add10KBtn = page.getByTestId('debug-mana-add-10k');
await expect(add10KBtn).toBeVisible({ timeout: 5000 });
await add10KBtn.click();
await waitForMs(page, 200);
// ── 3. Navigate to Crafting → Enchanter ────────────────────────────────────
await clickTab(page, 'craft');
+80 -67
View File
@@ -15,7 +15,7 @@ async function startFreshGame(page: Page) {
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForLoadState('networkidle');
await waitForMs(page, 3000); // wait for React hydration
await waitForMs(page, 3000);
}
async function clickTab(page: Page, label: string) {
@@ -30,12 +30,7 @@ async function clickBtn(page: Page, text: string) {
await waitForMs(page, 200);
}
/**
* Wait for the debug bridge (window.__TEST__) to be available.
* The bridge is loaded as a side-effect import in page.tsx.
*/
async function waitForBridge(page: Page) {
// Poll for the bridge with a longer timeout since hydration may take time
for (let attempt = 0; attempt < 30; attempt++) {
const ready = await page.evaluate(() => !!(window as any).__TEST__);
if (ready) return;
@@ -44,11 +39,24 @@ async function waitForBridge(page: Page) {
throw new Error('Debug bridge (window.__TEST__) not available after 30s');
}
/**
* Run n game ticks synchronously via the debug bridge.
*/
async function runTicks(page: Page, n: number) {
await page.evaluate((count: number) => {
(window as any).__TEST__.runTicks(count);
}, n);
}
/**
* Ticks needed to finish a craft of given hours.
* Each tick advances HOURS_PER_TICK (0.04) hours.
*/
function ticksForHours(hours: number): number {
return Math.ceil(hours / 0.04);
}
// ─── Recipe data for all 8 gear slots ────────────────────────────────────────
// We use store.equipItem() for equipping since the UI category→slot mapping
// has bugs (catalyst/sword/caster all → mainHand in getValidSlotsForCategory).
// ─── Gear set ────────────────────────────────────────────────────────────────
const GEAR_SET = [
{ slot: 'head', id: 'earthHelm', name: 'Earthen Helm', mt: 'earth', time: 3 },
@@ -61,15 +69,12 @@ const GEAR_SET = [
{ slot: 'accessory2', id: 'crystalAmulet', name: 'Crystal Pendant', mt: 'crystal', time: 4 },
];
// Total craft time: 31h. At HOURS_PER_TICK=0.04 & TICK_MS=200:
// 31/0.04 = 775 ticks × 200ms = 155 seconds real-time for all crafts.
// ─── Test ─────────────────────────────────────────────────────────────────────
test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
test('craft one piece per slot, equip all, verify effects on Stats tab', async ({ page }) => {
test.setTimeout(600_000); // 10 minutes
test.setTimeout(600_000);
const errors: string[] = [];
page.on('console', (msg) => {
@@ -82,59 +87,76 @@ test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
console.log('[TEST] Step 1: Starting fresh game...');
await startFreshGame(page);
await waitForMs(page, 1500);
console.log('[TEST] Waiting for bridge...');
await waitForBridge(page);
console.log('[TEST] Bridge ready!');
// ══════════════════════════════════════════════════════════════════════════
// STEP 2: Set up all prerequisites via Debug tab UI + store actions
// STEP 2: Set up all prerequisites via Debug tab UI
// ══════════════════════════════════════════════════════════════════════════
console.log('[TEST] Step 2: Setting up prerequisites...');
await clickTab(page, 'debug');
await waitForMs(page, 500);
// ── 2a. Unlock all attunements ───────────────────────────────────────────
console.log('[TEST] 2a. Unlocking attunements...');
// ── 2a. Open "Attunements" and unlock all ────────────────────────────────
const attunementsHeader = page.locator('button', { hasText: /^Attunements$/ }).first();
if (await attunementsHeader.isVisible({ timeout: 3000 })) {
await attunementsHeader.click();
await waitForMs(page, 300);
}
await clickBtn(page, 'Unlock All');
const unlockAllAttunements = page.getByTestId('debug-attunement-unlock-all');
await expect(unlockAllAttunements).toBeVisible({ timeout: 5000 });
await unlockAllAttunements.click();
await waitForMs(page, 500);
console.log('[TEST] 2b. Adding discipline XP...');
// ── 2b. Add discipline XP to unlock recipes ──────────────────────────────
// ── 2b. Activate and add discipline XP to unlock all fabricator recipes ──
// "Study Fabricator Recipes" needs 200 XP to unlock all 4 recipe tiers
// (earth@50, metal@100, sand@150, crystal@200).
await page.evaluate(() => {
const disc = (window as any).__TEST__.useDisciplineStore;
if (!disc) return;
const state = disc.getState();
const entry = state.disciplines['study-fabricator-recipes'];
if (entry) {
disc.setState({
disciplines: {
...state.disciplines,
'study-fabricator-recipes': { ...entry, xp: 1000 },
},
});
}
});
// We activate the discipline first, then add XP.
console.log('[TEST] 2b. Activating discipline and adding XP for recipe unlocks...');
const disciplinesHeader = page.locator('button', { hasText: /^Disciplines$/ }).first();
if (await disciplinesHeader.isVisible({ timeout: 3000 })) {
await disciplinesHeader.click();
await waitForMs(page, 300);
}
// Activate "Study Fabricator Recipes" discipline
const recipeToggleBtn = page.getByTestId('debug-discipline-toggle-study-fabricator-recipes');
await expect(recipeToggleBtn).toBeVisible({ timeout: 5000 });
await recipeToggleBtn.click();
await waitForMs(page, 200);
// Add 1000 XP (more than enough for all recipe tiers at 200 XP threshold)
const recipeAdd1KBtn = page.getByTestId('debug-discipline-add1k-study-fabricator-recipes');
await expect(recipeAdd1KBtn).toBeVisible({ timeout: 5000 });
await recipeAdd1KBtn.click();
await waitForMs(page, 300);
// Unlock all fabricator recipes via store.
// The discipline perks define which recipes unlock at which XP thresholds,
// but the actual unlock happens through processTick. For test reliability,
// we unlock directly via the store after setting the prerequisite discipline XP.
const allRecipeIds = GEAR_SET.map(g => g.id);
await page.evaluate((ids: string[]) => {
const craft = (window as any).__TEST__.useCraftingStore;
if (craft) craft.getState().unlockRecipes(ids);
}, allRecipeIds);
await waitForMs(page, 300);
// ── 2c. Unlock all elements ──────────────────────────────────────────────
console.log('[TEST] 2c. Unlocking elements...');
// ── 2c. Open "Elements" and unlock all ───────────────────────────────────
const elementsHeader = page.locator('button', { hasText: /^Elements$/ }).first();
if (await elementsHeader.isVisible({ timeout: 3000 })) {
await elementsHeader.click();
await waitForMs(page, 300);
}
await clickBtn(page, 'Unlock All Elements');
const unlockAllElements = page.getByTestId('debug-elements-unlock-all');
await expect(unlockAllElements).toBeVisible({ timeout: 5000 });
await unlockAllElements.click();
await waitForMs(page, 500);
console.log('[TEST] 2d. Boosting mana capacity...');
// ── 2d. Boost element mana capacity and fill via store ────────────────────
// ── 2d. Fill element mana ────────────────────────────────────────────────
console.log('[TEST] 2d. Filling element mana...');
await page.evaluate(() => {
const mana = (window as any).__TEST__.useManaStore;
if (!mana) return;
@@ -147,17 +169,18 @@ test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
});
await waitForMs(page, 300);
// ── 2e. Add starter materials ─────────────────────────────────────────────
console.log('[TEST] 2e. Adding starter materials...');
// ── 2e. Add materials via "Add Starter Materials" button ──────────────────
const addMatsBtn = page.locator('button', { hasText: 'Add Starter Materials' }).first();
const addMatsBtn = page.getByTestId('debug-quick-add-materials');
await expect(addMatsBtn).toBeVisible({ timeout: 5000 });
for (let i = 0; i < 50; i++) {
await addMatsBtn.click();
await waitForMs(page, 30);
}
await waitForMs(page, 500);
// ── 2f. Add crystalShard (not in starter materials) ──────────────────────
console.log('[TEST] 2f. Adding crystalShard...');
// ── 2f. Add crystalShard (not in starter materials) via store ─────────────
await page.evaluate(() => {
const craft = (window as any).__TEST__.useCraftingStore;
if (!craft) return;
@@ -168,14 +191,7 @@ test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
});
await waitForMs(page, 300);
console.log('[TEST] 2g. Unlocking recipes...');
// ── 2g. Unlock all fabricator recipes via store ───────────────────────────
await page.evaluate((ids: string[]) => {
const craft = (window as any).__TEST__.useCraftingStore;
if (!craft) return;
craft.getState().unlockRecipes(ids);
}, GEAR_SET.map(g => g.id));
await waitForMs(page, 300);
// Recipes are now unlocked via discipline perks (study-fabricator-recipes at 1000 XP)
// ══════════════════════════════════════════════════════════════════════════
// STEP 3: Craft each piece of gear sequentially
@@ -205,29 +221,29 @@ test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
await expect(recipeName).toBeVisible({ timeout: 5000 });
// Find the Craft button within this specific recipe card.
// The recipe card is a div ancestor of the recipe name text.
// Navigate up to the card container, then find the Craft button inside.
const recipeCard = recipeName.locator('xpath=ancestor::div[contains(@class, "p-3")]').first();
const craftBtn = recipeCard.locator('button', { hasText: /^Craft$/i }).first();
await expect(craftBtn).toBeVisible({ timeout: 5000 });
await craftBtn.click();
await waitForMs(page, 500);
// Wait for crafting to finish via the game loop.
// craftTime(h) / HOURS_PER_TICK(0.04) × TICK_MS(200ms) = craftTime × 5000ms
// Add a generous buffer.
const waitMs = gear.time * 5000 + 3000;
await waitForMs(page, waitMs);
// Run enough ticks to complete this craft.
// craftTime(h) / HOURS_PER_TICK(0.04) ticks needed, plus a small buffer.
const craftTicks = ticksForHours(gear.time) + 10;
console.log(`[TEST] Running ${craftTicks} ticks to craft ${gear.name}...`);
await runTicks(page, craftTicks);
await waitForMs(page, 500); // let React re-render
// Confirm crafting completed by checking currentAction via the bridge
const actionAfter = await page.evaluate(() =>
(window as any).__TEST__.useCombatStore.getState().currentAction
);
if (actionAfter !== 'meditate') {
// Craft might still be running — wait a bit more
console.log(`[TEST] currentAction=${actionAfter}, waiting more...`);
await waitForMs(page, 5000);
}
// Confirm crafting completed check that the item appears in equipment instances
const craftCompleted = await page.evaluate((itemName: string) => {
const craft = (window as any).__TEST__.useCraftingStore;
if (!craft) return false;
const state = craft.getState();
return Object.values(state.equipmentInstances).some(
(inst: any) => inst.name === itemName
);
}, gear.name);
expect(craftCompleted, `Crafting ${gear.name} did not complete`).toBe(true);
}
// ══════════════════════════════════════════════════════════════════════════
@@ -276,8 +292,6 @@ test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
}, GEAR_SET.map(g => ({ slot: g.slot, name: g.name })));
console.log('[TEST] Equip results:', equipResults);
// (All equipping done via store above)
// ══════════════════════════════════════════════════════════════════════════
// STEP 5: Verify gear effects on Equipment tab
// ══════════════════════════════════════════════════════════════════════════
@@ -306,7 +320,6 @@ test.describe('Fabricator Happy-Path: Craft → Equip → Verify Stats', () => {
expect(finalText).toContain(gear.name);
}
// ══════════════════════════════════════════════════════════════════════════
// STEP 7: No React errors
// ══════════════════════════════════════════════════════════════════════════
@@ -44,7 +44,7 @@ export function AttunementDebugSection() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<Button size="sm" variant="outline" onClick={handleUnlockAll} data-testid="debug-attunement-unlock-all">
<Unlock className="w-3 h-3 mr-1" /> Unlock All
</Button>
{Object.entries(ATTUNEMENTS_DEF).map(([id, def]) => {
@@ -53,7 +53,7 @@ export function AttunementDebugSection() {
const xp = attunements?.[id]?.experience || 0;
return (
<div key={id} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
<div key={id} data-testid={`debug-attunement-row-${id}`} className="flex items-center justify-between p-2 bg-gray-800/50 rounded">
<div className="flex items-center gap-2">
<span>{def.icon}</span>
<div>
@@ -68,6 +68,7 @@ export function AttunementDebugSection() {
size="sm"
variant="outline"
onClick={() => handleUnlockAttunement(id)}
data-testid={`debug-attunement-unlock-${id}`}
>
<Unlock className="w-3 h-3 mr-1" /> Unlock
</Button>
@@ -75,6 +76,7 @@ export function AttunementDebugSection() {
size="sm"
variant="outline"
onClick={() => handleAddAttunementXP(id, 100)}
data-testid={`debug-attunement-add100-${id}`}
>
+100 XP
</Button>
@@ -40,7 +40,7 @@ export function ElementDebugSection() {
</CardHeader>
<CardContent>
<div className="mb-3">
<Button size="sm" variant="outline" onClick={handleUnlockAll}>
<Button size="sm" variant="outline" onClick={handleUnlockAll} data-testid="debug-elements-unlock-all">
<Lock className="w-3 h-3 mr-1" /> Unlock All Elements
</Button>
</div>
@@ -68,6 +68,7 @@ export function ElementDebugSection() {
variant="outline"
className="mt-2"
onClick={() => handleUnlockElement(id)}
data-testid={`debug-element-unlock-${id}`}
>
<Lock className="w-3 h-3 mr-1" /> Unlock
</Button>
@@ -78,6 +79,7 @@ export function ElementDebugSection() {
variant="outline"
className="mt-2"
onClick={() => handleAddElementalMana(id, 10)}
data-testid={`debug-element-add10-${id}`}
>
+10
</Button>
@@ -181,10 +181,10 @@ function QuickActionsSection({ onUnlockBase, onAddStarterMaterials }: {
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onClick={onUnlockBase}>
<Button size="sm" variant="outline" onClick={onUnlockBase} data-testid="debug-quick-unlock-base">
Unlock All Base Elements
</Button>
<Button size="sm" variant="outline" onClick={onAddStarterMaterials}>
<Button size="sm" variant="outline" onClick={onAddStarterMaterials} data-testid="debug-quick-add-materials">
Add Starter Materials
</Button>
</div>