dev1/tests/e2e/04-career-explorer.core.spec.mjs

306 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/e2e/04-career-explorer.core.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Career Explorer core', () => {
// Enough headroom for cold suggestion builds, but still bounded.
test.setTimeout(40000);
test('suggestions visible (or reload) → open modal → add to comparison (+event bridge)', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000, // overlay should show quickly if it appears
cache: 60000, // wait for cache to populate after reload
tile: 8000, // find a tile quickly once cache exists
confirm: 7000, // modal/button appearances
tableRow: 20000, // time for table update after Save
};
// Accept alerts: inventory prompt and possible "already in comparison" duplicate
let sawInventoryAlert = false;
let sawDuplicateAlert = false;
page.on('dialog', async d => {
const msg = d.message();
if (/Interest Inventory/i.test(msg)) sawInventoryAlert = true;
if (/already in comparison/i.test(msg)) sawDuplicateAlert = true;
await d.accept();
});
// Helper: close any blocking overlay (priorities / meaning) by saving neutral defaults.
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dialog = overlay.locator('div[role="dialog"], div.bg-white').first();
// Select first non-empty option in each <select> (if any)
const selects = dialog.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const opts = selects.nth(i).locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await selects.nth(i).selectOption(v); break; }
}
}
// If theres a textbox, enter neutral “3”
const tb = dialog.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
// Prefer Save/Continue; else Cancel/Close; else Escape
const save = dialog.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dialog.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else { await page.keyboard.press('Escape'); }
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Helper: wait until careerSuggestionsCache has > 0 items
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
// Helper: full reload → wait for cache (no percentage polling)
async function reloadSuggestionsAndWait() {
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await closeAnyOverlay(); // ensure nothing intercepts
await reloadBtn.click();
// If an overlay appears, let it mount (dont require 100%)
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
// Real readiness check: cache populated
await waitForSuggestionsCache();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(
page.getByRole('heading', { name: /Explore Careers - use these tools/i })
).toBeVisible();
// If a priorities/meaning gate is up, close it first
await closeAnyOverlay();
// Ensure suggestions exist: try a reload, or complete inventory if server prompts for it.
const firstTile = page.locator('div.grid button').first();
if (!(await firstTile.isVisible({ timeout: 1500 }).catch(() => false))) {
await reloadSuggestionsAndWait();
// If server demanded Interest Inventory, complete it fast (dev has Randomize), then retry once.
if (sawInventoryAlert) {
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible().catch(() => false)) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
// Fallback: fill each page with Neutral (3)
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
await page.getByRole('button', { name: /^Submit$/ }).click();
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await closeAnyOverlay();
await reloadSuggestionsAndWait();
}
}
// Click a suggestion. Capture the title we clicked so we can assert by text later.
let clickedTitle = null;
// Prefer clicking by exact title from cache.
const cachedFirstTitle = await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) && arr.length ? String(arr[0].title || '') : null;
} catch { return null; }
});
if (cachedFirstTitle) {
const esc = cachedFirstTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const tileByTitle = page.getByRole('button', { name: new RegExp(`^${esc}$`) });
await expect(tileByTitle).toBeVisible({ timeout: TIME.tile });
clickedTitle = cachedFirstTitle;
await tileByTitle.click();
} else {
await expect(firstTile).toBeVisible({ timeout: TIME.tile });
clickedTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
}
// Wait for CareerModal, capture the definitive modal title if present (more reliable than tile text)
const modalH2 = page.getByRole('heading', { level: 2 });
if (await modalH2.isVisible({ timeout: TIME.confirm }).catch(() => false)) {
const t = await modalH2.first().textContent().catch(() => null);
if (t) clickedTitle = t.trim();
}
// Add to Comparison
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: TIME.confirm });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal: MUST click Save for the row to appear
{
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill any selects (choose '3' if available)
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
if (await sel.isVisible().catch(() => false)) {
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
}
// Fill any text/number inputs (set to '3' if empty)
const inputs = dlg.locator('input[type="number"], input[type="text"], [role="textbox"]');
const ic = await inputs.count().catch(() => 0);
for (let i = 0; i < ic; i++) {
const inp = inputs.nth(i);
if (await inp.isVisible().catch(() => false)) {
const val = (await inp.inputValue().catch(() => '')) || '';
if (!val) await inp.fill('3');
}
}
// Click Save (cover common label variants), else press Enter
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click();
} else {
await page.keyboard.press('Enter');
}
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// Assert success by presence of the clicked title in the table (or duplicate alert fallback).
const table = page.locator('table');
const rowLocator = table.locator('tbody tr');
await table.waitFor({ state: 'attached', timeout: 5000 }).catch(() => {});
await expect(async () => {
// If duplicate alert fired, consider success
if (sawDuplicateAlert) return true;
// Title-based presence (most reliable)
if (clickedTitle) {
const esc = clickedTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const cellWithTitle = table.getByText(new RegExp(esc, 'i'));
if (await cellWithTitle.isVisible().catch(() => false)) return true;
}
// Fallback: any increase in row count
const count = await rowLocator.count().catch(() => 0);
return count > 0;
}).toPass({ timeout: TIME.tableRow });
// ─────────────────────────────────────────────────────────────
// Event bridge checks (no duplicate add to avoid extra rows)
// ─────────────────────────────────────────────────────────────
// Pull a SOC from suggestions cache for events to target
const evSoc = await page.evaluate(() => {
try {
const arr = JSON.parse(localStorage.getItem('careerSuggestionsCache') || '[]');
return Array.isArray(arr) && arr.length ? (arr[0].code || arr[0].soc_code) : null;
} catch { return null; }
});
if (evSoc) {
// 1) open-career should open CareerModal
await page.evaluate((soc) => {
window.dispatchEvent(new CustomEvent('open-career', { detail: { socCode: soc } }));
}, evSoc);
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: TIME.confirm });
// Close modal to keep state clean
const closeBtnEv = page.getByRole('button', { name: /^Close$/i });
if (await closeBtnEv.isVisible().catch(() => false)) await closeBtnEv.click();
else await page.keyboard.press('Escape');
// 2) add-career should either add (if new) or trigger duplicate alert (if already present)
let sawDupAlertEv = false;
page.once('dialog', async d => {
if (/already in comparison/i.test(d.message())) sawDupAlertEv = true;
await d.accept();
});
const beforeRowsEv = await rowLocator.count().catch(() => 0);
await page.evaluate(({ soc, title }) => {
window.dispatchEvent(new CustomEvent('add-career', {
detail: { socCode: soc, careerName: title || '(name unavailable)' }
}));
}, { soc: evSoc, title: clickedTitle });
// If a ratings modal appears, save neutral defaults
const overlayEv = page.locator('div.fixed.inset-0');
if (await overlayEv.isVisible({ timeout: 1500 }).catch(() => false)) {
const dlg = overlayEv.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveEv = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done|OK)$/i });
if (await saveEv.isVisible({ timeout: 800 }).catch(() => false)) await saveEv.click();
await overlayEv.waitFor({ state: 'hidden', timeout: 4000 }).catch(() => {});
}
await expect(async () => {
const afterEv = await rowLocator.count().catch(() => 0);
// Pass if either duplicate alert fired OR row count increased
return sawDupAlertEv || afterEv > beforeRowsEv;
}).toPass({ timeout: 8000 });
}
});
});