Tabs is better for this yay Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
361 lines
17 KiB
JavaScript
361 lines
17 KiB
JavaScript
// Ported from chibistar/automation/starr/test-units-lessons-client.cjs.
|
|
// Core-responsibilities suite for Units & Lessons (see
|
|
// ~/Downloads/Units_and_Lessons_Test_Case_Documentation.txt):
|
|
// TC-UNT-020 Quiz sequential lock (course context, 2 units w/ required quizzes)
|
|
// TC-UNT-021 Open-access unit (no course) navigable directly
|
|
// TC-UNT-022 Locked unit shows upsell modal (not hidden), lists gating course/tier
|
|
// TC-UNT-023 Unit reader consumes lessons + reachable via grid->detail->read
|
|
// TC-UNT-024 Unit attached to zero courses is open to any logged-in learner
|
|
// TC-LSN-012 Direct navigation to /lessons/:uuid permalink resolves content
|
|
// TC-LSN-013 Lessons have no top-level browse page (no nav link, no listing route)
|
|
// TC-LSN-014 Cross-unit reading-progress completion (user_id+lesson_id only)
|
|
//
|
|
// Admin setup (beforeAll) builds all fixtures once; each test then opens its
|
|
// own client-session ("seedClient") page to exercise the learner-facing
|
|
// check. All [QA-TEST] fixtures are hard-deleted in afterAll.
|
|
const { test, expect } = require('@playwright/test');
|
|
const { APP_URL, USERS } = require('../../helpers/env');
|
|
const { loginAsAdmin, generateClientSession, buildCookies } = require('../../helpers/session');
|
|
const { cleanupQaTestFixtures } = require('../../helpers/cleanup');
|
|
|
|
async function settle(page, ms = 1200) {
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
await page.waitForTimeout(ms);
|
|
}
|
|
|
|
async function clickNextUntil(page, expectedHeadingRegex, maxTries = 3) {
|
|
for (let i = 0; i < maxTries; i++) {
|
|
await page.getByRole('button', { name: /^next$/i }).click();
|
|
await page.waitForTimeout(600);
|
|
if (await page.locator('h2', { hasText: expectedHeadingRegex }).count()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function addRequirement(page, labelRegex) {
|
|
const reqSelect = page.getByText(/add requirement/i).locator('..').first();
|
|
await reqSelect.scrollIntoViewIfNeeded();
|
|
await reqSelect.click();
|
|
await page.waitForTimeout(300);
|
|
const opt = page.getByRole('option', { name: labelRegex });
|
|
await opt.waitFor({ state: 'attached', timeout: 5000 });
|
|
await opt.evaluate((el) => el.click());
|
|
}
|
|
|
|
async function createBareUnit(page, title) {
|
|
await page.goto(`${APP_URL}/admin/units/add`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await page.locator('#title').fill(title);
|
|
await page.waitForTimeout(300);
|
|
await clickNextUntil(page, /^Lessons$/);
|
|
// REAL UI DRIFT (2026-08-25): the Lessons step now offers "Select" (attach
|
|
// existing) + "Create" (new) buttons — the source script's "Add Lesson"
|
|
// button no longer exists.
|
|
await page.getByRole('button', { name: /^create$/i }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.locator('input[name^="lessons."][name$=".title"]').first().fill(`${title} - Lesson`);
|
|
await page.waitForTimeout(300);
|
|
await clickNextUntil(page, /^Page Builder$/);
|
|
await clickNextUntil(page, /^Requirements$/);
|
|
await clickNextUntil(page, /^Review$/);
|
|
await page.getByRole('button', { name: /^create unit$/i }).click();
|
|
await settle(page, 1800);
|
|
await page.goto(`${APP_URL}/admin/units`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
const row = page.locator('table tbody tr', { hasText: title }).first();
|
|
await row.locator('button').last().click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByText(/^view info$/i).click();
|
|
await page.waitForURL('**/view', { timeout: 10000 });
|
|
await settle(page, 1000);
|
|
return page.url().match(/units\/(\d+)\/view/)?.[1];
|
|
}
|
|
|
|
async function addQuizWithQuestion(page, unitId, questionText) {
|
|
await page.goto(`${APP_URL}/admin/units/${unitId}/quiz/edit`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await page.getByRole('button', { name: /^multiple choice$/i }).click();
|
|
await page.waitForTimeout(400);
|
|
await page.locator('textarea, input[placeholder*="question" i]').first().fill(questionText);
|
|
const optionInputs = page.locator('input[placeholder*="option" i]');
|
|
const texts = ['Correct Answer', 'Wrong Answer B', 'Wrong Answer C', 'Wrong Answer D'];
|
|
const n = await optionInputs.count();
|
|
for (let i = 0; i < n; i++) await optionInputs.nth(i).fill(texts[i] ?? `Option ${i + 1}`);
|
|
await page.getByRole('button', { name: /^save quiz$/i }).click();
|
|
await settle(page, 1500);
|
|
}
|
|
|
|
async function courseWizardHappyPath(page, title, code, { pickPaidTier = false } = {}) {
|
|
await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' });
|
|
await settle(page, 1200);
|
|
await page.locator('#title').fill(title);
|
|
await page.locator('#course_code').fill(code);
|
|
|
|
let pickedPaidTier = false;
|
|
if (pickPaidTier) {
|
|
const subscrTrigger = page.locator('button[role="combobox"]').nth(1);
|
|
if (await subscrTrigger.count()) {
|
|
await subscrTrigger.click();
|
|
await page.waitForTimeout(300);
|
|
const premiumOpt = page.getByRole('option', { name: /premium|pro|paid|plus/i }).first();
|
|
if (await premiumOpt.count()) { await premiumOpt.click(); pickedPaidTier = true; }
|
|
else await page.keyboard.press('Escape');
|
|
}
|
|
}
|
|
|
|
await page.getByRole('button', { name: /add objective/i }).click();
|
|
await page.locator('input[placeholder="Objective 1"]').fill('QA automation objective.');
|
|
await page.waitForTimeout(400);
|
|
await page.getByRole('button', { name: /^next$/i }).click();
|
|
await page.waitForTimeout(1200);
|
|
if (!(await page.getByText(/prerequisites/i).count())) {
|
|
await page.getByRole('button', { name: /^next$/i }).click();
|
|
await page.waitForTimeout(1200);
|
|
}
|
|
await page.getByRole('button', { name: /^next$/i }).click();
|
|
await page.waitForTimeout(800);
|
|
await page.getByRole('button', { name: /^next$/i }).click();
|
|
await page.waitForTimeout(800);
|
|
await page.getByRole('button', { name: /^finish$/i }).click();
|
|
await page.waitForURL('**/view', { timeout: 15000 }).catch(() => {});
|
|
await settle(page, 1800);
|
|
const courseId = page.url().match(/courses\/(\d+)\/view/)?.[1];
|
|
return { courseId, pickedPaidTier };
|
|
}
|
|
|
|
async function attachUnitToCourse(page, courseId, unitTitle) {
|
|
await page.goto(`${APP_URL}/admin/courses/${courseId}/units`, { waitUntil: 'networkidle' });
|
|
await settle(page, 1800);
|
|
let attachBtn = page.getByRole('button', { name: /attach existing/i });
|
|
if (!(await attachBtn.count())) {
|
|
await page.reload({ waitUntil: 'networkidle' });
|
|
await settle(page, 1500);
|
|
attachBtn = page.getByRole('button', { name: /attach existing/i });
|
|
}
|
|
await attachBtn.click();
|
|
await page.waitForTimeout(600);
|
|
await page.locator('input[placeholder="Search units..."]').fill(unitTitle);
|
|
await page.waitForTimeout(500);
|
|
await page.getByText(unitTitle).first().click();
|
|
await page.getByRole('button', { name: /^attach/i }).click();
|
|
await settle(page, 1800);
|
|
}
|
|
|
|
const TS = Date.now().toString().slice(-6);
|
|
const COURSE_TITLE = `[QA-TEST] Sequential Quiz Course ${TS}`;
|
|
const COURSE_CODE = `QA-SEQ-${TS}`;
|
|
const UNIT1_TITLE = `[QA-TEST] Seq Unit 1 ${TS}`;
|
|
const UNIT2_TITLE = `[QA-TEST] Seq Unit 2 ${TS}`;
|
|
const OPEN_UNIT_TITLE = `[QA-TEST] Open Access Unit ${TS}`;
|
|
const LOCKED_UNIT_TITLE = `[QA-TEST] Locked Premium Unit ${TS}`;
|
|
const LOCKED_COURSE_TITLE = `[QA-TEST] Premium Gating Course ${TS}`;
|
|
const LOCKED_COURSE_CODE = `QA-PREM-${TS}`;
|
|
const CROSS_LESSON_TITLE = `[QA-TEST] Cross Unit Lesson ${TS}`;
|
|
const CROSS_UNIT_A_TITLE = `[QA-TEST] Cross Progress Unit A ${TS}`;
|
|
const CROSS_UNIT_B_TITLE = `[QA-TEST] Cross Progress Unit B ${TS}`;
|
|
|
|
test.describe.serial('Units & Lessons — client core responsibilities', () => {
|
|
test.setTimeout(180_000);
|
|
/** @type {{courseId: string, lockedCourseId: string, pickedPaidTier: boolean, lessonUuid: string|null}} */
|
|
let shared = {};
|
|
let clientRefreshToken;
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
test.setTimeout(300_000); // this fixture-setup chain (6 units, 2 courses, a lesson) takes well over the default hook timeout
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await loginAsAdmin(page);
|
|
|
|
const openUnitId = await createBareUnit(page, OPEN_UNIT_TITLE);
|
|
const lockedUnitId = await createBareUnit(page, LOCKED_UNIT_TITLE);
|
|
|
|
const unit1Id = await createBareUnit(page, UNIT1_TITLE);
|
|
await addQuizWithQuestion(page, unit1Id, 'Sequential Quiz Unit 1 question?');
|
|
await page.goto(`${APP_URL}/admin/units/${unit1Id}/edit`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await addRequirement(page, /pass the quiz/i);
|
|
await page.getByRole('button', { name: /save requirements/i }).click();
|
|
await settle(page, 1500);
|
|
|
|
const unit2Id = await createBareUnit(page, UNIT2_TITLE);
|
|
await addQuizWithQuestion(page, unit2Id, 'Sequential Quiz Unit 2 question?');
|
|
await page.goto(`${APP_URL}/admin/units/${unit2Id}/edit`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await addRequirement(page, /pass the quiz/i);
|
|
await page.getByRole('button', { name: /save requirements/i }).click();
|
|
await settle(page, 1500);
|
|
|
|
// Standalone lesson attached to two units, for the cross-unit progress check.
|
|
await page.goto(`${APP_URL}/admin/lessons/add`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await page.locator('#title').fill(CROSS_LESSON_TITLE);
|
|
await page.waitForTimeout(300);
|
|
await clickNextUntil(page, /^Page Builder$/);
|
|
// REAL UI DRIFT (2026-08-25): Page Builder is now opened via its own
|
|
// "Open Page Builder" button before the block toolbar is reachable.
|
|
await page.getByRole('button', { name: /open page builder/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /add block/i }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.locator('[role="menu"]').getByText('Text', { exact: true }).click();
|
|
await page.waitForTimeout(400);
|
|
const editor = page.locator('[contenteditable="true"]').first();
|
|
await editor.click();
|
|
await editor.type('Cross-unit reading-progress test content. Scroll to the bottom to complete.');
|
|
await page.waitForTimeout(300);
|
|
// The block editor lives inside a Drawer (StepPageBuilder in
|
|
// AddLibraryLesson.jsx) — "Done" just closes it (form state is already
|
|
// updated live via setValue as you type), but the wizard's own Next
|
|
// button is unreachable while the drawer is open.
|
|
await page.getByRole('button', { name: /^done$/i }).click();
|
|
await page.waitForTimeout(300);
|
|
await clickNextUntil(page, /^Requirements$/);
|
|
await clickNextUntil(page, /^Review$/);
|
|
await page.getByRole('button', { name: /^create lesson$/i }).click();
|
|
await settle(page, 1800);
|
|
|
|
const crossUnitAId = await createBareUnit(page, CROSS_UNIT_A_TITLE);
|
|
const crossUnitBId = await createBareUnit(page, CROSS_UNIT_B_TITLE);
|
|
for (const uid of [crossUnitAId, crossUnitBId]) {
|
|
await page.goto(`${APP_URL}/admin/units/${uid}/view`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await page.getByRole('button', { name: /attach existing/i }).click();
|
|
await page.waitForTimeout(600);
|
|
await page.locator('input[placeholder="Search lessons..."]').fill(CROSS_LESSON_TITLE);
|
|
await page.waitForTimeout(500);
|
|
await page.getByText(CROSS_LESSON_TITLE).first().click();
|
|
await page.getByRole('button', { name: /^attach/i }).click();
|
|
await settle(page, 1600);
|
|
}
|
|
|
|
const { courseId } = await courseWizardHappyPath(page, COURSE_TITLE, COURSE_CODE);
|
|
await attachUnitToCourse(page, courseId, UNIT1_TITLE);
|
|
await attachUnitToCourse(page, courseId, UNIT2_TITLE);
|
|
|
|
const { courseId: lockedCourseId, pickedPaidTier } = await courseWizardHappyPath(
|
|
page, LOCKED_COURSE_TITLE, LOCKED_COURSE_CODE, { pickPaidTier: true },
|
|
);
|
|
await attachUnitToCourse(page, lockedCourseId, LOCKED_UNIT_TITLE);
|
|
|
|
// Best-effort scrape of the cross-unit lesson's uuid for the permalink test.
|
|
await page.goto(`${APP_URL}/admin/lessons`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
const crossLessonRow = page.locator('table tbody tr', { hasText: CROSS_LESSON_TITLE }).first();
|
|
await crossLessonRow.locator('button').last().click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByText(/^view info$/i).click();
|
|
await page.waitForURL('**/lessons/**', { timeout: 10000 });
|
|
await settle(page, 1000);
|
|
const lessonUuid = await page.evaluate(() => {
|
|
const m = document.body.innerHTML.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
|
return m ? m[0] : null;
|
|
});
|
|
|
|
shared = { courseId, lockedCourseId, pickedPaidTier, lessonUuid };
|
|
({ refreshToken: clientRefreshToken } = await generateClientSession(USERS.seedClient.email));
|
|
|
|
await context.close();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
const removed = await cleanupQaTestFixtures();
|
|
console.log('QA-TEST fixtures removed:', removed);
|
|
});
|
|
|
|
test.beforeEach(async ({ context }) => {
|
|
await context.addCookies(buildCookies(clientRefreshToken));
|
|
});
|
|
|
|
test('TC-UNT-021 / TC-UNT-024: unit with zero course attachments is open to any learner', async ({ page }) => {
|
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
const openCard = page.getByText(OPEN_UNIT_TITLE).first();
|
|
await openCard.waitFor({ state: 'visible', timeout: 10000 });
|
|
await openCard.click();
|
|
await settle(page);
|
|
expect(page.url()).toContain('/units/');
|
|
expect(page.url().endsWith('/units')).toBe(false);
|
|
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
|
|
});
|
|
|
|
test('TC-UNT-022: locked unit shows upsell modal listing the gating course', async ({ page }) => {
|
|
test.skip(!shared.pickedPaidTier, 'No paid/premium tier available in this environment\'s tier catalog to gate the course with — same environment limitation the original script hit.');
|
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
const lockedCard = page.getByText(LOCKED_UNIT_TITLE).first();
|
|
await lockedCard.waitFor({ state: 'visible', timeout: 10000 });
|
|
await lockedCard.click();
|
|
await settle(page);
|
|
await expect(page.locator('[role="dialog"]')).toHaveCount(1);
|
|
await expect(page.getByText(LOCKED_COURSE_TITLE)).toBeVisible();
|
|
expect(page.url().endsWith('/units')).toBe(true);
|
|
});
|
|
|
|
test('TC-UNT-023: unit reader reachable via grid -> detail -> read', async ({ page }) => {
|
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await page.getByText(OPEN_UNIT_TITLE).first().click();
|
|
await settle(page);
|
|
expect(page.url()).toContain('/units/');
|
|
await page.getByRole('button', { name: /start|read|continue/i }).first().click();
|
|
await settle(page);
|
|
expect(page.url()).toContain('/read');
|
|
});
|
|
|
|
test('TC-LSN-013: lessons have no top-level browse page', async ({ page }) => {
|
|
const navHasLessonsLink = await page.locator('nav a, header a').filter({ hasText: /^lessons$/i }).count();
|
|
expect(navHasLessonsLink).toBe(0);
|
|
});
|
|
|
|
test('TC-LSN-012: direct navigation to /lessons/:uuid permalink resolves content', async ({ page }) => {
|
|
test.skip(!shared.lessonUuid, 'Could not scrape a lesson uuid from the admin View Info page.');
|
|
await page.goto(`${APP_URL}/lessons/${shared.lessonUuid}`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await expect(page.getByText(CROSS_LESSON_TITLE)).toBeVisible();
|
|
});
|
|
|
|
test('TC-LSN-014: cross-unit reading-progress completion (user_id+lesson_id only)', async ({ page }) => {
|
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
const unitACard = page.getByText(CROSS_UNIT_A_TITLE).first();
|
|
await unitACard.waitFor({ state: 'visible', timeout: 10000 });
|
|
await unitACard.click();
|
|
await settle(page);
|
|
const startBtnA = page.getByRole('button', { name: /start|read|continue/i }).first();
|
|
if (await startBtnA.count()) {
|
|
await startBtnA.click();
|
|
await settle(page);
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
await page.waitForTimeout(1200);
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
await page.waitForTimeout(1800);
|
|
}
|
|
|
|
await page.goto(`${APP_URL}/units`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
const unitBCard = page.getByText(CROSS_UNIT_B_TITLE).first();
|
|
await unitBCard.click();
|
|
await settle(page);
|
|
let completed = await page.getByText(/completed/i).count() > 0;
|
|
if (!completed) {
|
|
const startBtnB = page.getByRole('button', { name: /start|read|continue/i }).first();
|
|
if (await startBtnB.count()) {
|
|
await startBtnB.click();
|
|
await settle(page);
|
|
completed = await page.getByText(/completed/i).count() > 0;
|
|
}
|
|
}
|
|
expect(completed).toBe(true);
|
|
});
|
|
|
|
test('TC-UNT-020: quiz sequential lock blocks unit 2 before unit 1 is passed', async ({ page }) => {
|
|
await page.goto(`${APP_URL}/course/${shared.courseId}/unit`, { waitUntil: 'networkidle' });
|
|
await settle(page);
|
|
await page.getByText(UNIT2_TITLE).first().click();
|
|
await settle(page);
|
|
const quizTab = page.getByText(/quiz/i).first();
|
|
if (await quizTab.count()) { await quizTab.click(); await settle(page); }
|
|
await expect(page.getByText(/complete previous quiz|complete previous quizzes first/i)).toBeVisible();
|
|
});
|
|
});
|