711 lines
39 KiB
JavaScript
711 lines
39 KiB
JavaScript
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
// Run only after a fresh production build. All account and server data is isolated
|
|
// under this script's temporary directory; screenshots/results are the only output.
|
|
const directory = path.resolve('playtest-artifacts/stat-rulesets');
|
|
const staticDir = path.resolve('dist');
|
|
const contentDir = path.resolve('content');
|
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-stat-rulesets-'));
|
|
const databaseKey = 'healer-man.profile-database.v1';
|
|
const requiredNativeRows = Object.freeze({
|
|
attributes: [
|
|
'Strength (STR)', 'Stamina (STA)', 'Intellect (INT)', 'Wisdom (WIS)', 'Agility (AGI)', 'Health', 'Mana (MP)',
|
|
],
|
|
offense: [
|
|
'Physical attack (ATK)', 'Physical damage (DMG)', 'Magical attack (MATK)', 'Magical damage (MDMG)',
|
|
'Magical damage points', 'Magic power rate', 'Physical hit (points)', 'Magical hit (points)',
|
|
'Physical critical (points)', 'Magical critical (points)', 'Healing power rate', 'Healing points',
|
|
],
|
|
defense: [
|
|
'Physical defense (DEF)', 'Magical defense (MDEF)', 'Physical dodge (points)', 'Magical dodge (points)', 'Parry (points)',
|
|
],
|
|
});
|
|
|
|
function cleanupDataDir() {
|
|
const resolvedDataDir = path.resolve(dataDir);
|
|
const resolvedTempDir = path.resolve(os.tmpdir());
|
|
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|
|
|| !path.basename(resolvedDataDir).startsWith('healer-man-stat-rulesets-')) {
|
|
throw new Error(`Refusing cleanup outside dedicated QA temp directory: ${resolvedDataDir}`);
|
|
}
|
|
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
|
|
}
|
|
|
|
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
|
|
cleanupDataDir();
|
|
throw new Error('A fresh production build is required before this playtest.');
|
|
}
|
|
const assetsDirectory = path.join(staticDir, 'assets');
|
|
const assets = fs.readdirSync(assetsDirectory);
|
|
const mainFile = assets.find((file) => /^index-.*\.js$/.test(file));
|
|
const combatFile = assets.find((file) => /^combatStore-.*\.js$/.test(file));
|
|
if (!mainFile || !combatFile) {
|
|
cleanupDataDir();
|
|
throw new Error('Could not locate the production app and combat-store modules in dist/assets.');
|
|
}
|
|
const moduleUrls = assets
|
|
.filter((file) => /^(?:index|combatStore|partyStore)-.*\.js$/.test(file))
|
|
.map((file) => `/assets/${file}`);
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
|
|
async function capture(page, name, fullPage = false) {
|
|
const file = path.join(directory, `${name}.png`);
|
|
await page.screenshot({ path: file, fullPage });
|
|
return file;
|
|
}
|
|
|
|
async function waitForScreen(page, selector) {
|
|
await page.locator(selector).waitFor({ timeout: 180_000 });
|
|
}
|
|
|
|
async function waitForGameReady(page) {
|
|
await waitForScreen(page, '.party-frames');
|
|
await page.waitForFunction(async () => {
|
|
const stores = await window.statRulesetStores();
|
|
const overlay = document.querySelector('.loading-screen');
|
|
const visible = overlay && (() => {
|
|
const style = getComputedStyle(overlay);
|
|
const bounds = overlay.getBoundingClientRect();
|
|
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
|
|
&& bounds.width > 0 && bounds.height > 0;
|
|
})();
|
|
const ready = stores.game?.getState().assetStatus === 'ready' && !visible;
|
|
if (!ready) {
|
|
window.__statRulesetVisualReadySince = 0;
|
|
return false;
|
|
}
|
|
if (!window.__statRulesetVisualReadySince) window.__statRulesetVisualReadySince = performance.now();
|
|
return performance.now() - window.__statRulesetVisualReadySince >= 900;
|
|
}, null, { timeout: 180_000, polling: 100 });
|
|
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
|
|
await page.locator('.scene-shell canvas').waitFor({ state: 'visible', timeout: 30_000 });
|
|
await page.locator('.party-frame--player[aria-label^="Target "]').waitFor();
|
|
await page.locator('.party-frame--player .party-frame__health[aria-label="Player health"]').waitFor();
|
|
await page.waitForTimeout(1_200);
|
|
}
|
|
|
|
async function createRomPriest(page, name) {
|
|
await page.locator('.roster-new').click();
|
|
await waitForScreen(page, '.character-create-screen');
|
|
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
|
|
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
|
|
await page.getByRole('button', { name: /Customize & name/ }).click();
|
|
await page.getByLabel('Character name', { exact: true }).fill(name);
|
|
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
|
|
await waitForScreen(page, '.character-select-screen');
|
|
const created = await page.evaluate(async (characterName) => {
|
|
const { shell } = await window.statRulesetStores();
|
|
const normalizedName = characterName.replace(/[^A-Za-z]/g, '').slice(0, 12).toLowerCase();
|
|
const character = shell.getState().characters.find((entry) => entry.name.toLowerCase() === normalizedName);
|
|
return character ? { id: character.id, name: character.name, classId: character.classId, statRuleset: character.statRuleset } : null;
|
|
}, name);
|
|
assert.deepEqual(created && { classId: created.classId, statRuleset: created.statRuleset }, {
|
|
classId: 'rom-priest',
|
|
statRuleset: 'rom',
|
|
}, 'New RoM characters are created with Native RoM stats.');
|
|
return created;
|
|
}
|
|
|
|
async function chooseRosterCharacter(page, name) {
|
|
await waitForScreen(page, '.character-select-screen');
|
|
await page.locator('.roster-character').filter({ hasText: name }).click();
|
|
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
|
|
await waitForScreen(page, '.main-menu-screen');
|
|
await page.locator('.main-menu-character').waitFor();
|
|
}
|
|
|
|
async function seedProfilesAndRemoveLegacyRule(page, ids) {
|
|
return page.evaluate(({ nativeId, legacyId, databaseKey }) => {
|
|
const database = JSON.parse(localStorage.getItem(databaseKey) || 'null');
|
|
if (!database?.rosters) throw new Error('The offline profile database was not found.');
|
|
const owners = Object.entries(database.rosters);
|
|
const nativeRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === nativeId);
|
|
const legacyRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === legacyId);
|
|
if (!nativeRecord || !legacyRecord) throw new Error('Created test profiles were missing from local storage.');
|
|
|
|
const inventory = (suffix) => [{
|
|
instanceId: `qa-robes-${suffix}`,
|
|
itemId: 10001,
|
|
itemKey: `qa-robes-${suffix}`,
|
|
name: `Field-Test Robe ${suffix}`,
|
|
quality: 'rare',
|
|
inventorySlot: 'chest',
|
|
armorType: 'cloth',
|
|
weaponType: null,
|
|
itemLevel: 30,
|
|
sourceItemLevel: 30,
|
|
stats: {
|
|
health: 0, mana: 0, strength: 0, agility: 0, stamina: 7, intellect: 3, spirit: 0,
|
|
armor: 18, attackPower: 0, rangedAttackPower: 0, spellPower: 0, criticalStrikeRating: 0,
|
|
hitRating: 0, hasteRating: 0, expertiseRating: 0, armorPenetrationRating: 0,
|
|
defenseRating: 0, dodgeRating: 0, parryRating: 0, blockRating: 0, blockValue: 0,
|
|
resilienceRating: 0, spellPenetration: 0, mp5: 0, healthPer5: 0, arcaneResistance: 0,
|
|
fireResistance: 0, frostResistance: 0, natureResistance: 0, shadowResistance: 0,
|
|
meleeWeapon: null, rangedWeapon: null,
|
|
},
|
|
quantity: 1,
|
|
sourceBossKey: 'qa-stat-ruleset-fixture',
|
|
sourceBossName: 'Stat Ruleset QA Fixture',
|
|
acquiredAt: Date.now(),
|
|
}];
|
|
const prepare = (profile, suffix, ruleset) => {
|
|
const item = inventory(suffix)[0];
|
|
Object.assign(profile, {
|
|
level: 30,
|
|
experience: 0,
|
|
secondaryClassId: 'rom-mage',
|
|
talentRanks: { 'rom-priest-qa-fixture': 1 },
|
|
glyphIds: ['qa-preserved-glyph'],
|
|
actionBindings: { 'primary-1': 'rom-priest-primary-1' },
|
|
actionLoadouts: { 'rom-priest:rom-mage': { 'primary-1': 'rom-priest-primary-1' } },
|
|
inventory: [item],
|
|
equipment: { player: { chest: item.instanceId } },
|
|
});
|
|
if (ruleset) profile.statRuleset = ruleset;
|
|
else delete profile.statRuleset;
|
|
};
|
|
prepare(nativeRecord, 'native', 'rom');
|
|
prepare(legacyRecord, 'legacy', null);
|
|
localStorage.setItem(databaseKey, JSON.stringify(database));
|
|
return {
|
|
nativeId,
|
|
legacyId,
|
|
legacyRuleRemoved: !Object.hasOwn(legacyRecord, 'statRuleset'),
|
|
fixtureItemIds: ['qa-robes-native', 'qa-robes-legacy'],
|
|
};
|
|
}, { ...ids, databaseKey });
|
|
}
|
|
|
|
async function profileSnapshot(page, id) {
|
|
return page.evaluate(async (characterId) => {
|
|
const { shell } = await window.statRulesetStores();
|
|
const profile = shell.getState().characters.find((entry) => entry.id === characterId);
|
|
if (!profile) throw new Error(`Profile ${characterId} was not found.`);
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
classId: profile.classId,
|
|
statRuleset: profile.statRuleset,
|
|
level: profile.level,
|
|
secondaryClassId: profile.secondaryClassId,
|
|
talentRanks: profile.talentRanks,
|
|
glyphIds: profile.glyphIds,
|
|
actionBindings: profile.actionBindings,
|
|
actionLoadouts: profile.actionLoadouts,
|
|
inventory: profile.inventory,
|
|
equipment: profile.equipment,
|
|
settings: profile.settings,
|
|
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
|
|
};
|
|
}, id);
|
|
}
|
|
|
|
function buildSnapshot(profile) {
|
|
return {
|
|
level: profile.level,
|
|
secondaryClassId: profile.secondaryClassId,
|
|
talentRanks: profile.talentRanks,
|
|
glyphIds: profile.glyphIds,
|
|
actionBindings: profile.actionBindings,
|
|
actionLoadouts: profile.actionLoadouts,
|
|
inventory: profile.inventory,
|
|
equipment: profile.equipment,
|
|
settings: profile.settings,
|
|
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
|
|
};
|
|
}
|
|
|
|
async function chooseMode(page, ruleset) {
|
|
const label = ruleset === 'rom' ? 'Native RoM' : 'WoW compatibility';
|
|
const button = page.locator('.main-menu-character').getByRole('button', { name: label, exact: true });
|
|
await button.waitFor();
|
|
await button.click();
|
|
await page.waitForFunction(async (expected) => {
|
|
const { shell } = await window.statRulesetStores();
|
|
const state = shell.getState();
|
|
return state.characters.find((entry) => entry.id === state.selectedCharacterId)?.statRuleset === expected;
|
|
}, ruleset);
|
|
assert.equal(await button.getAttribute('aria-pressed'), 'true');
|
|
}
|
|
|
|
async function selectWailingCaverns(page) {
|
|
await page.getByRole('button', { name: /Dungeons/ }).click();
|
|
await waitForScreen(page, '.dungeon-select-screen');
|
|
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
|
|
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
|
|
await page.getByRole('button', { name: /Choose role/ }).click();
|
|
const damageRole = page.locator('.dungeon-role-option--damage');
|
|
if (await damageRole.count()) await damageRole.first().click();
|
|
else await page.locator('.dungeon-role-option').first().click();
|
|
await waitForGameReady(page);
|
|
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
|
|
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
|
|
await page.waitForTimeout(750);
|
|
}
|
|
|
|
async function openStatsPanel(page) {
|
|
const scene = page.locator('.scene-shell');
|
|
await scene.focus();
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
|
|
if (overlay === 'pause') break;
|
|
await scene.press('Escape');
|
|
await page.waitForTimeout(100);
|
|
}
|
|
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
|
|
assert.equal(overlay, 'pause', 'Escape on the focused dungeon view opens the visible pause menu.');
|
|
await page.locator('.pause-menu').waitFor();
|
|
await page.getByRole('button', { name: 'Inventory & equipment', exact: true }).click();
|
|
await page.locator('.equipment-panel').waitFor();
|
|
await page.locator('.equipment-panel .equipment-character-stats').waitFor();
|
|
}
|
|
|
|
async function readStatsPanelPage(page) {
|
|
const panel = page.locator('.equipment-panel .equipment-character-stats');
|
|
const stats = await panel.evaluate((list) => ({
|
|
label: list.getAttribute('aria-label'),
|
|
rows: [...list.querySelectorAll('div')].map((row) => ({
|
|
label: row.querySelector('dt')?.textContent?.trim() ?? '',
|
|
value: row.querySelector('dd')?.textContent?.trim() ?? '',
|
|
})),
|
|
vitals: [...document.querySelectorAll('.equipment-character-vitals span')].map((element) => element.innerText.trim()),
|
|
}));
|
|
return {
|
|
...stats,
|
|
caption: await page.locator('.equipment-character-stats__note').innerText().catch(() => ''),
|
|
};
|
|
}
|
|
|
|
(async () => {
|
|
let runtime;
|
|
let browser;
|
|
let page;
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
const httpErrors = [];
|
|
const responseErrors = [];
|
|
const results = {
|
|
outputDirectory: directory,
|
|
buildChunks: { main: mainFile, combat: combatFile, runtimeModules: moduleUrls },
|
|
fixtures: { legacyProfileIsSynthetic: true, liveCombatGuardIsSynthetic: true, onlineActivityLock: 'attempted only if its store is exposed by production entry chunks' },
|
|
screenshots: {},
|
|
observations: {},
|
|
};
|
|
|
|
try {
|
|
const { createGameServer } = await import('../server/server.mjs');
|
|
runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
|
|
await new Promise((resolve, reject) => {
|
|
runtime.server.once('error', reject);
|
|
runtime.server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
|
|
results.origin = origin;
|
|
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
|
|
page = await context.newPage();
|
|
page.setDefaultTimeout(90_000);
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
|
|
});
|
|
page.on('response', (response) => {
|
|
if (response.status() >= 400) {
|
|
const error = { status: response.status(), url: response.url() };
|
|
responseErrors.push(error);
|
|
if (response.status() >= 500) httpErrors.push(error);
|
|
}
|
|
});
|
|
await page.addInitScript((urls) => {
|
|
window.statRulesetStores = async () => {
|
|
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
|
|
const stores = exports.filter((value) => {
|
|
try { return typeof value?.getState === 'function'; } catch { return false; }
|
|
});
|
|
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
|
|
return {
|
|
shell: stores.find((value) => typeof stateOf(value)?.setSelectedCharacterStatRuleset === 'function'),
|
|
combat: stores.find((value) => typeof stateOf(value)?.castAbility === 'function' && 'mobs' in stateOf(value)),
|
|
game: stores.find((value) => typeof stateOf(value)?.openOverlay === 'function' && 'assetStatus' in stateOf(value)),
|
|
party: stores.find((value) => Array.isArray(stateOf(value)?.members) && typeof stateOf(value)?.setCommand === 'function'),
|
|
online: stores.find((value) => 'snapshot' in (stateOf(value) ?? {}) && typeof stateOf(value)?.setFillWithAi === 'function'),
|
|
storeCount: stores.length,
|
|
};
|
|
};
|
|
}, moduleUrls);
|
|
|
|
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
|
await page.getByRole('button', { name: /Play offline/ }).click();
|
|
await waitForScreen(page, '.character-select-screen');
|
|
const nativeCreated = await createRomPriest(page, 'Nativestats');
|
|
const legacyCreated = await createRomPriest(page, 'Legacystats');
|
|
const seeded = await seedProfilesAndRemoveLegacyRule(page, {
|
|
nativeId: nativeCreated.id,
|
|
legacyId: legacyCreated.id,
|
|
});
|
|
assert.equal(seeded.legacyRuleRemoved, true);
|
|
results.observations.createdNativeProfile = nativeCreated;
|
|
results.observations.legacyFixture = seeded;
|
|
|
|
// A reload forces the real repository read/migration path for the old RoM save.
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForScreen(page, '.character-select-screen');
|
|
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
|
|
await page.locator('.roster-character').filter({ hasText: nativeCreated.name }).click();
|
|
await page.waitForTimeout(3_000);
|
|
const rosterPreview = await page.locator('.character-preview').evaluate((preview) => {
|
|
const canvas = preview.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
caption: preview.querySelector('figcaption')?.innerText.trim() ?? '',
|
|
canvas: bounds ? { width: bounds.width, height: bounds.height } : null,
|
|
};
|
|
});
|
|
assert.ok(rosterPreview.canvas?.width > 100 && rosterPreview.canvas?.height > 100, 'The saved RoM character body preview has a rendered canvas.');
|
|
results.observations.rosterPreview = rosterPreview;
|
|
const migratedNative = await profileSnapshot(page, nativeCreated.id);
|
|
const migratedLegacy = await profileSnapshot(page, legacyCreated.id);
|
|
assert.equal(migratedNative.statRuleset, 'rom');
|
|
assert.equal(migratedLegacy.statRuleset, 'wow', 'A legacy saved RoM profile without the field retains WoW-compatible behavior.');
|
|
assert.equal(migratedLegacy.secondaryClassId, 'rom-mage');
|
|
assert.equal(migratedLegacy.inventory[0]?.instanceId, 'qa-robes-legacy');
|
|
assert.deepEqual(migratedLegacy.equipment.player, { chest: 'qa-robes-legacy' });
|
|
results.observations.afterLegacyMigration = {
|
|
native: { statRuleset: migratedNative.statRuleset, classId: migratedNative.classId },
|
|
legacy: { statRuleset: migratedLegacy.statRuleset, classId: migratedLegacy.classId, level: migratedLegacy.level },
|
|
savedRuleAfterMigration: JSON.parse(await page.evaluate((key) => localStorage.getItem(key), databaseKey))
|
|
.rosters['offline-roster'].find((entry) => entry.id === legacyCreated.id).statRuleset,
|
|
};
|
|
results.screenshots.rosterNative = await capture(page, '01-roster-native-rom');
|
|
|
|
await chooseRosterCharacter(page, nativeCreated.name);
|
|
await page.locator('.main-menu-character').getByRole('group', { name: 'RoM stat profile' }).waitFor();
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
|
|
const nativeBuild = buildSnapshot(await profileSnapshot(page, nativeCreated.id));
|
|
|
|
// Exercise the actual menu controls and browser persistence for one profile.
|
|
await chooseMode(page, 'wow');
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow');
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForScreen(page, '.character-select-screen');
|
|
await chooseRosterCharacter(page, nativeCreated.name);
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow', 'The visible WoW-compatible selection survives reload.');
|
|
results.screenshots.menuWowCompatibility = await capture(page, '02-menu-wow-compatibility');
|
|
await chooseMode(page, 'rom');
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
|
|
|
|
// The second RoM profile must retain its legacy WoW setting independently.
|
|
await page.getByRole('button', { name: 'Change character', exact: true }).click();
|
|
await waitForScreen(page, '.character-select-screen');
|
|
await chooseRosterCharacter(page, legacyCreated.name);
|
|
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
|
|
await chooseMode(page, 'rom');
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
|
|
await chooseMode(page, 'wow');
|
|
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
|
|
await page.getByRole('button', { name: 'Change character', exact: true }).click();
|
|
await waitForScreen(page, '.character-select-screen');
|
|
await chooseRosterCharacter(page, nativeCreated.name);
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom', 'Changing the second profile never changes the first profile.');
|
|
|
|
// Measure the selector itself at desktop and narrow phone widths.
|
|
const desktopSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
|
|
const bounds = fieldset.getBoundingClientRect();
|
|
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
|
|
const overlapsSummary = [...fieldset.parentElement.children]
|
|
.filter((element) => element !== fieldset)
|
|
.some((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
|
|
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
|
|
});
|
|
const controls = [...fieldset.querySelectorAll('button')].map((button) => {
|
|
const rect = button.getBoundingClientRect();
|
|
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
|
|
});
|
|
return {
|
|
clientWidth: fieldset.clientWidth,
|
|
scrollWidth: fieldset.scrollWidth,
|
|
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
|
|
sidebar: { left: parent.left, right: parent.right },
|
|
overlapsSummary,
|
|
controls,
|
|
};
|
|
});
|
|
assert.ok(desktopSelectorLayout.scrollWidth <= desktopSelectorLayout.clientWidth + 1, 'Desktop selector content fits its fieldset.');
|
|
assert.equal(desktopSelectorLayout.overlapsSummary, false, 'Desktop selector does not overlap its character label.');
|
|
results.observations.desktopSelectorLayout = desktopSelectorLayout;
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.waitForTimeout(350);
|
|
const narrowSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
|
|
const bounds = fieldset.getBoundingClientRect();
|
|
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
|
|
const overlapsSummary = [...fieldset.parentElement.children]
|
|
.filter((element) => element !== fieldset)
|
|
.some((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
|
|
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
|
|
});
|
|
return {
|
|
viewportWidth: window.innerWidth,
|
|
documentWidth: document.documentElement.scrollWidth,
|
|
clientWidth: fieldset.clientWidth,
|
|
scrollWidth: fieldset.scrollWidth,
|
|
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
|
|
sidebar: { left: parent.left, right: parent.right },
|
|
overlapsSummary,
|
|
controls: [...fieldset.querySelectorAll('button')].map((button) => {
|
|
const rect = button.getBoundingClientRect();
|
|
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
|
|
}),
|
|
};
|
|
});
|
|
assert.ok(narrowSelectorLayout.fieldset.left >= narrowSelectorLayout.sidebar.left - 1);
|
|
assert.ok(narrowSelectorLayout.fieldset.right <= narrowSelectorLayout.sidebar.right + 1);
|
|
assert.ok(narrowSelectorLayout.scrollWidth <= narrowSelectorLayout.clientWidth + 1, 'Phone selector does not overflow its fieldset.');
|
|
assert.equal(narrowSelectorLayout.overlapsSummary, false, 'Phone selector does not overlap its character label.');
|
|
assert.ok(narrowSelectorLayout.controls.every((control) => control.left >= narrowSelectorLayout.fieldset.left - 1
|
|
&& control.right <= narrowSelectorLayout.fieldset.right + 1), 'Phone selector buttons stay inside the fieldset.');
|
|
results.observations.narrowSelectorLayout = narrowSelectorLayout;
|
|
results.screenshots.menuSelectorMobile = await capture(page, '03-menu-selector-mobile', true);
|
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
|
|
await selectWailingCaverns(page);
|
|
results.screenshots.nativeDungeonGameplay = await capture(page, '04-native-dungeon-gameplay-overlays-closed');
|
|
const nativeRuntime = await page.evaluate(async () => {
|
|
const { shell, combat, party } = await window.statRulesetStores();
|
|
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
|
|
const state = combat.getState();
|
|
return {
|
|
phase: shell.getState().phase,
|
|
profile: profile && { name: profile.name, statRuleset: profile.statRuleset },
|
|
classId: state.classId,
|
|
statRuleset: state.statRuleset,
|
|
health: state.health,
|
|
maxHealth: state.maxHealth,
|
|
resource: state.resource,
|
|
maxResource: state.maxResource,
|
|
resourceName: state.resourceName,
|
|
resourcePools: state.resourcePools,
|
|
inventory: state.inventory.map((item) => ({ instanceId: item.instanceId, name: item.name })),
|
|
playerEquipment: state.equipment.player,
|
|
party: party?.getState().members.map((member) => ({
|
|
name: member.name,
|
|
classId: member.classId,
|
|
statRuleset: member.statRuleset ?? 'wow',
|
|
})) ?? [],
|
|
};
|
|
});
|
|
assert.equal(nativeRuntime.phase, 'game');
|
|
assert.equal(nativeRuntime.profile?.statRuleset, 'rom');
|
|
assert.equal(nativeRuntime.classId, 'rom-priest');
|
|
assert.equal(nativeRuntime.statRuleset, 'rom', 'The selected profile reaches the actual combat runtime.');
|
|
assert.ok(nativeRuntime.maxHealth > 0 && nativeRuntime.maxResource > 0);
|
|
assert.ok(nativeRuntime.inventory.some((item) => item.instanceId === 'qa-robes-native'));
|
|
assert.equal(nativeRuntime.playerEquipment.chest, 'qa-robes-native', 'The equipped test item reaches the actual dungeon runtime.');
|
|
for (const member of nativeRuntime.party) {
|
|
if (member.classId.startsWith('rom-')) assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
|
|
}
|
|
results.observations.nativeDungeonRuntime = nativeRuntime;
|
|
await openStatsPanel(page);
|
|
const nativePanelPages = {};
|
|
for (const [index, pageName] of ['attributes', 'offense', 'defense'].entries()) {
|
|
if (pageName !== 'attributes') {
|
|
const tab = page.locator('.equipment-stat-tabs').getByRole('tab', { name: pageName, exact: true });
|
|
await tab.click();
|
|
await page.waitForFunction((name) => {
|
|
const selected = document.querySelector('.equipment-stat-tabs [role="tab"][aria-selected="true"]');
|
|
return selected?.textContent?.trim() === name;
|
|
}, pageName);
|
|
}
|
|
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
|
|
const panelPage = await readStatsPanelPage(page);
|
|
const labels = new Set(panelPage.rows.map((row) => row.label));
|
|
const missing = requiredNativeRows[pageName].filter((label) => !labels.has(label));
|
|
assert.deepEqual(missing, [], `Native RoM ${pageName} page is missing fields: ${missing.join(', ')}`);
|
|
assert.equal(panelPage.caption, 'Native RoM stats · shared dungeon combat rules');
|
|
nativePanelPages[pageName] = panelPage;
|
|
results.screenshots[`nativeStats${pageName[0].toUpperCase()}${pageName.slice(1)}Desktop`] = await capture(
|
|
page,
|
|
`04-native-stats-${pageName}-desktop`,
|
|
);
|
|
}
|
|
const renderedMeters = await page.locator('.party-frame--player').evaluate((frame) => {
|
|
const health = frame.querySelector('.party-frame__health[aria-label="Player health"]');
|
|
const resource = frame.querySelector('.party-frame__resource');
|
|
return {
|
|
health: {
|
|
current: Number(health?.getAttribute('aria-valuenow')),
|
|
maximum: Number(health?.getAttribute('aria-valuemax')),
|
|
text: health?.querySelector('b')?.textContent?.trim() ?? '',
|
|
},
|
|
resourceTitle: resource?.getAttribute('title') ?? '',
|
|
};
|
|
});
|
|
assert.ok(Math.abs(renderedMeters.health.maximum - nativeRuntime.maxHealth) <= 1, 'Rendered health maximum agrees with combat state.');
|
|
assert.ok(Math.abs(renderedMeters.health.current - nativeRuntime.health) <= 2, 'Rendered health current agrees with combat state.');
|
|
const renderedResource = renderedMeters.resourceTitle.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(.+)$/);
|
|
assert.ok(renderedResource, 'Rendered resource meter exposes current, maximum, and resource type.');
|
|
assert.ok(Math.abs(Number(renderedResource[2]) - nativeRuntime.maxResource) <= 1, 'Rendered resource maximum agrees with combat state.');
|
|
assert.ok(Math.abs(Number(renderedResource[1]) - Math.floor(nativeRuntime.resource)) <= 1, 'Rendered resource current agrees with combat state.');
|
|
assert.equal(renderedResource[3], nativeRuntime.resourceName, 'Rendered resource type agrees with combat state.');
|
|
const attributeValues = new Map(nativePanelPages.attributes.rows.map((row) => [row.label, Number(row.value.replaceAll(',', ''))]));
|
|
assert.ok(Math.abs(attributeValues.get('Health') - nativeRuntime.maxHealth) <= 1, 'Native panel Health agrees with combat max health.');
|
|
assert.ok(Math.abs(attributeValues.get('Mana (MP)') - nativeRuntime.maxResource) <= 1, 'Native panel Mana agrees with combat max resource.');
|
|
assert.ok(nativePanelPages.attributes.vitals.some((row) => row.includes(`Combat health ${Math.round(nativeRuntime.maxHealth).toLocaleString()}`)));
|
|
results.observations.nativePanelPages = { ...nativePanelPages, renderedMeters };
|
|
await page.locator('.equipment-stat-tabs').getByRole('tab', { name: 'attributes', exact: true }).click();
|
|
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.waitForTimeout(350);
|
|
results.screenshots.nativeDungeonStatsMobile = await capture(page, '07-native-stats-attributes-mobile', true);
|
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
|
await page.getByRole('button', { name: 'Return to pause menu' }).click();
|
|
await page.locator('.pause-menu').waitFor();
|
|
|
|
// Synthetic shell-route fixture over an installed live combat runtime. It
|
|
// tests the runtime's engaged-mob guard without leaving or saving a real run.
|
|
const blockedBefore = await profileSnapshot(page, nativeCreated.id);
|
|
const syntheticLock = await page.evaluate(async () => {
|
|
const { shell, combat } = await window.statRulesetStores();
|
|
shell.setState({ phase: 'main-menu', activeCharacter: null });
|
|
combat.setState({ mobs: { '__qa-synthetic-live-mob__': { engaged: true, dead: false } } });
|
|
return { phase: shell.getState().phase, statRuleset: shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId)?.statRuleset };
|
|
});
|
|
assert.equal(syntheticLock.phase, 'main-menu');
|
|
await waitForScreen(page, '.main-menu-screen');
|
|
await page.locator('.main-menu-character').getByRole('button', { name: 'WoW compatibility', exact: true }).click();
|
|
const blockedAttempt = await page.evaluate(async () => {
|
|
const { shell } = await window.statRulesetStores();
|
|
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
|
|
return { phase: shell.getState().phase, statRuleset: profile?.statRuleset, notice: shell.getState().notice };
|
|
});
|
|
assert.equal(blockedAttempt.statRuleset, blockedBefore.statRuleset);
|
|
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
|
|
results.observations.syntheticLiveCombatLock = {
|
|
beforeClick: syntheticLock,
|
|
afterClick: blockedAttempt,
|
|
fixture: 'Synthetic main-menu route and engaged mob injected over the local live runtime; no run was left or saved.',
|
|
};
|
|
await page.evaluate(async () => {
|
|
const { shell, combat } = await window.statRulesetStores();
|
|
combat.setState({ mobs: {} });
|
|
shell.setState({ phase: 'game', activeCharacter: null });
|
|
});
|
|
await page.locator('.pause-menu').waitFor({ timeout: 30_000 });
|
|
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
|
|
await waitForScreen(page, '.main-menu-screen');
|
|
|
|
// Optionally set a synthetic online activity if its store is exposed by the
|
|
// production entry chunks; absence is recorded rather than faked via UI state.
|
|
const onlineFixture = await page.evaluate(async () => {
|
|
const { online } = await window.statRulesetStores();
|
|
if (!online) return { available: false };
|
|
online.setState({ snapshot: {
|
|
group: { id: 'qa-synthetic-online-group', members: [], activity: { id: 'qa-synthetic-live-activity' } },
|
|
invitations: [],
|
|
} });
|
|
return { available: true };
|
|
});
|
|
if (onlineFixture.available) {
|
|
await page.waitForFunction(() => [...document.querySelectorAll('.main-menu-character button')].every((button) => button.disabled));
|
|
const blocked = await page.evaluate(async () => {
|
|
const { shell } = await window.statRulesetStores();
|
|
const accepted = shell.getState().setSelectedCharacterStatRuleset('wow');
|
|
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
|
|
return { accepted, statRuleset: profile?.statRuleset };
|
|
});
|
|
assert.equal(blocked.accepted, false);
|
|
assert.equal(blocked.statRuleset, 'rom');
|
|
const profileButtonDisabled = await page.locator('.main-menu-character [aria-label="RoM stat profile"] button').evaluateAll((buttons) => buttons.every((button) => button.disabled));
|
|
assert.equal(profileButtonDisabled, true, 'A synthetic online activity disables the profile selector.');
|
|
results.observations.syntheticOnlineActivityLock = { ...onlineFixture, ...blocked, profileButtonDisabled, fixture: 'Synthetic online group activity injected into the local store.' };
|
|
await page.evaluate(async () => (await window.statRulesetStores()).online.getState().reset());
|
|
} else {
|
|
results.observations.syntheticOnlineActivityLock = { available: false, note: 'The online-group store was not exported by production entry chunks.' };
|
|
}
|
|
|
|
// Visible WoW switch after leaving the run must preserve all saved build data.
|
|
const beforeWowSwitch = await profileSnapshot(page, nativeCreated.id);
|
|
const beforeWowBuild = buildSnapshot(beforeWowSwitch);
|
|
assert.deepEqual(
|
|
{ ...beforeWowBuild, dungeonRunCheckpoint: null },
|
|
{ ...nativeBuild, dungeonRunCheckpoint: null },
|
|
'The first dungeon run leaves the character build and inventory intact.',
|
|
);
|
|
await chooseMode(page, 'wow');
|
|
const afterWowSwitch = await profileSnapshot(page, nativeCreated.id);
|
|
assert.equal(afterWowSwitch.statRuleset, 'wow');
|
|
assert.deepEqual(buildSnapshot(afterWowSwitch), beforeWowBuild, 'Switching rulesets preserves class pair, talents, bars, inventory, gear, and checkpoint.');
|
|
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
|
|
results.observations.persistedBuildAfterWowSwitch = buildSnapshot(afterWowSwitch);
|
|
results.screenshots.menuWowAfterDungeon = await capture(page, '06-menu-wow-after-dungeon');
|
|
|
|
// A second actual run verifies the saved compatibility selection reaches runtime.
|
|
await selectWailingCaverns(page);
|
|
results.screenshots.wowDungeonGameplay = await capture(page, '08-wow-compatibility-dungeon-gameplay-overlays-closed');
|
|
const wowRuntime = await page.evaluate(async () => {
|
|
const { shell, combat } = await window.statRulesetStores();
|
|
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
|
|
const state = combat.getState();
|
|
return { statRuleset: state.statRuleset, profileRuleset: profile?.statRuleset, classId: state.classId };
|
|
});
|
|
assert.deepEqual(wowRuntime, { statRuleset: 'wow', profileRuleset: 'wow', classId: 'rom-priest' });
|
|
results.observations.wowCompatibilityDungeonRuntime = wowRuntime;
|
|
await capture(page, '07-wow-compatibility-dungeon');
|
|
await page.keyboard.press('Escape');
|
|
await page.locator('.pause-menu').waitFor();
|
|
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
|
|
await waitForScreen(page, '.main-menu-screen');
|
|
const profileBeforeFinalReload = await profileSnapshot(page, nativeCreated.id);
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForScreen(page, '.character-select-screen');
|
|
await chooseRosterCharacter(page, nativeCreated.name);
|
|
const finalNative = await profileSnapshot(page, nativeCreated.id);
|
|
const finalLegacy = await profileSnapshot(page, legacyCreated.id);
|
|
assert.equal(finalNative.statRuleset, 'wow');
|
|
assert.equal(finalLegacy.statRuleset, 'wow');
|
|
assert.deepEqual(finalNative.dungeonRunCheckpoint, profileBeforeFinalReload.dungeonRunCheckpoint,
|
|
'The saved checkpoint survives a reload alongside the selected ruleset.');
|
|
assert.deepEqual(
|
|
{ ...buildSnapshot(finalNative), dungeonRunCheckpoint: null },
|
|
{ ...nativeBuild, dungeonRunCheckpoint: null },
|
|
'Native to WoW switching and the dungeon run preserve the character build and inventory.',
|
|
);
|
|
results.observations.finalProfiles = {
|
|
native: { name: finalNative.name, classId: finalNative.classId, statRuleset: finalNative.statRuleset },
|
|
legacy: { name: finalLegacy.name, classId: finalLegacy.classId, statRuleset: finalLegacy.statRuleset },
|
|
checkpointRetainedAfterReload: Boolean(finalNative.dungeonRunCheckpoint),
|
|
};
|
|
assert.deepEqual(pageErrors, [], 'No uncaught browser errors occurred.');
|
|
assert.deepEqual(httpErrors, [], 'No production asset or server failures occurred.');
|
|
const unexpectedResponseErrors = responseErrors.filter((error) => !(error.status === 404 && new URL(error.url).pathname === '/favicon.ico'));
|
|
assert.deepEqual(unexpectedResponseErrors, [], 'No app assets or API resources failed to load (the browser may request a missing optional favicon).');
|
|
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
|
|
results.status = 'passed';
|
|
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
|
|
console.log(`Stat ruleset browser playtest passed: ${directory}`);
|
|
} catch (error) {
|
|
results.status = 'failed';
|
|
results.error = error?.stack ?? String(error);
|
|
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
|
|
if (page) {
|
|
results.screenshots.failure = await capture(page, 'failure', true).catch(() => null);
|
|
}
|
|
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
|
|
throw error;
|
|
} finally {
|
|
await browser?.close().catch(() => {});
|
|
if (runtime?.server.listening) await new Promise((resolve) => runtime.server.close(resolve));
|
|
cleanupDataDir();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|