306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
// 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 there’s 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 (don’t 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 });
|
||
}
|
||
});
|
||
});
|