From b28938274735c8aa1821f4234a327bcfd34ace2d Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Tue, 22 Sep 2026 01:50:22 +0800 Subject: [PATCH] E2E test is not yet implemented Because we will need to make another plan. Signed-off-by: Kenneth Obsequio --- .github/workflows/test.yml | 34 -- .gitignore | 3 - apps/e2e/helpers/cleanup.js | 34 -- apps/e2e/helpers/db.js | 23 -- apps/e2e/helpers/env.js | 33 -- apps/e2e/helpers/session.js | 55 --- apps/e2e/helpers/viewport.js | 37 -- apps/e2e/package.json | 15 - apps/e2e/playwright.config.js | 25 -- .../tests/achievements-certificates/TODO.md | 2 - apps/e2e/tests/advertisements/admin.spec.js | 166 -------- apps/e2e/tests/courses/admin.spec.js | 373 ------------------ apps/e2e/tests/dashboard/TODO.md | 2 - apps/e2e/tests/notifications/TODO.md | 2 - apps/e2e/tests/profile-account/TODO.md | 2 - apps/e2e/tests/tasks/TODO.md | 11 - apps/e2e/tests/tier-plans/TODO.md | 11 - apps/e2e/tests/units-lessons/client.spec.js | 360 ----------------- package.json | 3 +- 19 files changed, 1 insertion(+), 1190 deletions(-) delete mode 100644 .github/workflows/test.yml delete mode 100644 apps/e2e/helpers/cleanup.js delete mode 100644 apps/e2e/helpers/db.js delete mode 100644 apps/e2e/helpers/env.js delete mode 100644 apps/e2e/helpers/session.js delete mode 100644 apps/e2e/helpers/viewport.js delete mode 100644 apps/e2e/package.json delete mode 100644 apps/e2e/playwright.config.js delete mode 100644 apps/e2e/tests/achievements-certificates/TODO.md delete mode 100644 apps/e2e/tests/advertisements/admin.spec.js delete mode 100644 apps/e2e/tests/courses/admin.spec.js delete mode 100644 apps/e2e/tests/dashboard/TODO.md delete mode 100644 apps/e2e/tests/notifications/TODO.md delete mode 100644 apps/e2e/tests/profile-account/TODO.md delete mode 100644 apps/e2e/tests/tasks/TODO.md delete mode 100644 apps/e2e/tests/tier-plans/TODO.md delete mode 100644 apps/e2e/tests/units-lessons/client.spec.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index ac283e6..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: E2E tests - -on: - pull_request: {} - push: - branches: [main, qas] - workflow_dispatch: {} - -jobs: - e2e: - runs-on: [self-hosted, ux] - steps: - - uses: actions/checkout@v4 - - - name: Install dependencies - run: pnpm install --frozen-lockfile=false - - - name: Install Playwright Firefox - run: pnpm --filter e2e exec playwright install firefox --with-deps - - - name: Run e2e suite - run: pnpm --filter e2e test - - - name: Upload Playwright report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: apps/e2e/playwright-report/ - retention-days: 14 - - - name: Sweep QA-TEST fixtures - if: always() - run: node -e "require('./apps/e2e/helpers/cleanup').cleanupQaTestFixtures().then(r => console.log('Removed:', r)).catch(e => { console.error(e); process.exit(0); })" diff --git a/.gitignore b/.gitignore index cdb8eff..df8b940 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,6 @@ coverage/ # ── Testing ─────────────────────────────────────────────────────────────────── test-results/ -playwright-report/ -blob-report/ -.last-run.json # ── Uploads / Temp ──────────────────────────────────────────────────────────── uploads/ diff --git a/apps/e2e/helpers/cleanup.js b/apps/e2e/helpers/cleanup.js deleted file mode 100644 index 98a9061..0000000 --- a/apps/e2e/helpers/cleanup.js +++ /dev/null @@ -1,34 +0,0 @@ -const { Course } = require('../../api/models/courses/courses.mdl'); -const Unit = require('../../api/models/courses/units.mdl'); -const Lesson = require('../../api/models/courses/lessons.mdl'); -const { TaskList, Task } = require('../../api/models/task/task.mdl'); -const Advertisement = require('../../api/models/advertisements/advertisements.mdl'); -const TierPlan = require('../../api/models/tiers/tier_plans.mdl'); -const { Op } = require('sequelize'); - -const QA_PREFIX = '[QA-TEST'; - -const TARGETS = [ - { model: Task, column: 'name' }, - { model: TaskList, column: 'name' }, - { model: Advertisement, column: 'headline' }, - { model: Unit, column: 'title' }, - { model: Lesson, column: 'title' }, - { model: Course, column: 'title' }, - { model: TierPlan, column: 'label' }, -]; - -/** Hard-deletes every QA-TEST-prefixed fixture row across all modules this suite touches. */ -async function cleanupQaTestFixtures() { - const removed = {}; - for (const { model, column } of TARGETS) { - const count = await model.destroy({ - where: { [column]: { [Op.like]: `${QA_PREFIX}%` } }, - force: true, // hard delete, not just paranoid soft-delete — mirrors the manual "archive then permanently delete" cleanup flow - }); - if (count) removed[model.name] = count; - } - return removed; -} - -module.exports = { cleanupQaTestFixtures, QA_PREFIX }; diff --git a/apps/e2e/helpers/db.js b/apps/e2e/helpers/db.js deleted file mode 100644 index d5f78d5..0000000 --- a/apps/e2e/helpers/db.js +++ /dev/null @@ -1,23 +0,0 @@ -// Reuses the app's own Sequelize connection/config instead of duplicating -// DB_HOST/DB_PORT/... wiring — env.js pre-loads apps/api/.env so db.config.js -// picks up the real dev credentials when it runs its own (no-op, already-set) -// dotenv.config() call. -require('./env'); -const sequelize = require('../../api/config/db.config'); -const Users = require('../../api/models/users/users.mdl'); - -async function getUserIdByEmail(email) { - const user = await Users.findOne({ where: { email }, attributes: ['user_id'] }); - if (!user) throw new Error(`No user found for email ${email} — check the dev DB seed / helpers/env.js USERS config.`); - return String(user.user_id); -} - -async function getPendingOtp(user_id) { - const rows = await sequelize.query( - `SELECT otp_code FROM users WHERE user_id = :uid`, - { replacements: { uid: user_id }, type: sequelize.QueryTypes.SELECT }, - ); - return rows[0]?.otp_code ?? null; -} - -module.exports = { sequelize, getUserIdByEmail, getPendingOtp }; diff --git a/apps/e2e/helpers/env.js b/apps/e2e/helpers/env.js deleted file mode 100644 index 25f6cc1..0000000 --- a/apps/e2e/helpers/env.js +++ /dev/null @@ -1,33 +0,0 @@ -// Loads real dev secrets from apps/api/.env (gitignored — never committed). -// Falls back to apps/api/.env-development (git-tracked shared dev creds) for -// anything .env doesn't override, matching how apps/api itself resolves config. -const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '../../api/.env') }); -require('dotenv').config({ path: path.join(__dirname, '../../api/.env-development') }); - -const APP_URL = process.env.E2E_APP_URL || 'http://localhost:5173'; -const API_URL = process.env.E2E_API_URL || 'http://localhost:3024/api'; - -const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; -if (!JWT_REFRESH_SECRET || JWT_REFRESH_SECRET.startsWith('CHANGE_ME')) { - throw new Error('JWT_REFRESH_SECRET missing/placeholder — check apps/api/.env has real dev secrets.'); -} - -// Known test accounts. user_id is intentionally NOT hardcoded here — these -// accounts get recreated over time (see chibistar/automation/starr/config.cjs -// history) which silently breaks hardcoded ids. helpers/session.js looks the -// id up by email at call time instead. -const USERS = { - admin: { - email: process.env.E2E_ADMIN_EMAIL || 'k80308392@gmail.com', - password: process.env.E2E_ADMIN_PASSWORD || 'Test123@', - acc_type: 'admin', - }, - seedClient: { - // Synthetic, no real owner — safe default for read-only/client-facing checks. - email: process.env.E2E_SEED_CLIENT_EMAIL || 'veronica.castro@example.com', - acc_type: 'user', - }, -}; - -module.exports = { APP_URL, API_URL, JWT_REFRESH_SECRET, USERS }; diff --git a/apps/e2e/helpers/session.js b/apps/e2e/helpers/session.js deleted file mode 100644 index e8fbf6f..0000000 --- a/apps/e2e/helpers/session.js +++ /dev/null @@ -1,55 +0,0 @@ -const jwt = require('jsonwebtoken'); -const crypto = require('crypto'); -const { APP_URL, JWT_REFRESH_SECRET, USERS } = require('./env'); -const { sequelize, getUserIdByEmail, getPendingOtp } = require('./db'); - -/** Build the cookie jar for context.addCookies() from a refresh token. */ -function buildCookies(refreshToken, csrfToken = 'e2e-csrf') { - return [ - { name: 'refreshToken', value: refreshToken, domain: 'localhost', path: '/', httpOnly: true, secure: false, sameSite: 'Lax' }, - { name: 'csrfToken', value: csrfToken, domain: 'localhost', path: '/', httpOnly: false, secure: false, sameSite: 'Lax' }, - { name: 'sidebar_state', value: 'false', domain: 'localhost', path: '/', httpOnly: false, secure: false, sameSite: 'Lax' }, - ]; -} - -/** - * Generate a fresh refresh token + DB session row for a client user, keyed - * by email (not a hardcoded user_id — those drift as test accounts get - * recreated, see chibistar/automation/starr/config.cjs history). - */ -async function generateClientSession(email) { - const user_id = await getUserIdByEmail(email); - const refreshToken = jwt.sign({ user_id }, JWT_REFRESH_SECRET, { expiresIn: '7d' }); - const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); - - await sequelize.query( - `INSERT INTO user_sessions (user_id, refresh_token_hash, is_active, login_info, "createdAt", "updatedAt") - VALUES (:uid, :hash, true, '{}', NOW(), NOW())`, - { replacements: { uid: user_id, hash: tokenHash }, type: sequelize.QueryTypes.INSERT }, - ); - - return { user_id, refreshToken }; -} - -/** - * Authenticate as admin via cookie injection (same mechanism as - * generateClientSession), not the UI login form. - * - * REAL FINDING (2026-08-25): the admin account (k80308392@gmail.com) is - * Google-OAuth-only now — submitting the UI email/password form returns - * "Login failed — Please log in with Google." This matches a prior finding - * that the *live*-domain admin session had gone Google-only, but it turns - * out to be true here against localhost too, so the email+password+OTP flow - * this helper originally ported from chibistar/automation/starr/config.cjs - * (loginAsAdmin) is dead — forging the session cookie is the only way in now. - */ -async function loginAsAdmin(page) { - const { refreshToken } = await generateClientSession(USERS.admin.email); - await page.context().addCookies(buildCookies(refreshToken)); - await page.goto(`${APP_URL}/admin`, { waitUntil: 'networkidle' }); - if (/\/login/.test(page.url())) { - throw new Error('Cookie-based admin auth bounced to /login — check JWT_REFRESH_SECRET / user_sessions wiring.'); - } -} - -module.exports = { buildCookies, generateClientSession, loginAsAdmin }; diff --git a/apps/e2e/helpers/viewport.js b/apps/e2e/helpers/viewport.js deleted file mode 100644 index 136b422..0000000 --- a/apps/e2e/helpers/viewport.js +++ /dev/null @@ -1,37 +0,0 @@ -const { execSync } = require('child_process'); - -// Real monitor resolution instead of a hardcoded viewport — this suite runs -// on whatever box the self-hosted runner/dev machine happens to be (varies: -// 1366x768, 1920x1080, ...). Memoized per process. -let _cachedViewport = null; - -function detectViewport() { - if (_cachedViewport) return _cachedViewport; - const FALLBACK = { width: 1366, height: 768 }; - let detected = null; - try { - if (process.platform === 'linux') { - const out = execSync('xrandr --current 2>/dev/null', { encoding: 'utf8' }); - const m = out.match(/(\d+)x(\d+)\s+[\d.]+\*/); - if (m) detected = { width: Number(m[1]), height: Number(m[2]) }; - } else if (process.platform === 'win32') { - const out = execSync( - 'powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; ' + - '$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; Write-Output \\"$($b.Width)x$($b.Height)\\""', - { encoding: 'utf8', windowsHide: true }, - ).trim(); - const m = out.match(/(\d+)x(\d+)/); - if (m) detected = { width: Number(m[1]), height: Number(m[2]) }; - } else if (process.platform === 'darwin') { - const out = execSync('system_profiler SPDisplaysDataType 2>/dev/null', { encoding: 'utf8' }); - const m = out.match(/Resolution:\s*(\d+)\s*x\s*(\d+)/); - if (m) detected = { width: Number(m[1]), height: Number(m[2]) }; - } - } catch { - // No xrandr on a headless CI box, PowerShell blocked, etc. — fall back. - } - _cachedViewport = detected ?? FALLBACK; - return _cachedViewport; -} - -module.exports = { detectViewport }; diff --git a/apps/e2e/package.json b/apps/e2e/package.json deleted file mode 100644 index ab6e49c..0000000 --- a/apps/e2e/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "e2e", - "private": true, - "scripts": { - "test": "playwright test" - }, - "devDependencies": { - "@playwright/test": "^1.48.0", - "dotenv": "^16.4.5", - "jsonwebtoken": "^9.0.3", - "pg": "^8.11.3", - "pg-hstore": "^2.3.4", - "sequelize": "^6.37.8" - } -} diff --git a/apps/e2e/playwright.config.js b/apps/e2e/playwright.config.js deleted file mode 100644 index a2965a4..0000000 --- a/apps/e2e/playwright.config.js +++ /dev/null @@ -1,25 +0,0 @@ -const { defineConfig, devices } = require('@playwright/test'); - -module.exports = defineConfig({ - testDir: './tests', - fullyParallel: false, // specs share/mutate DB fixtures tagged by module — keep runs serial to avoid cross-spec QA-TEST collisions - workers: 1, - retries: process.env.CI ? 1 : 0, - reporter: [['html', { open: 'never' }], ['list']], - timeout: 120_000, - use: { - baseURL: 'http://localhost:5173', - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - projects: [ - { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, - ], - webServer: { - command: 'pnpm --dir ../.. dev', - url: 'http://localhost:5173', - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, -}); diff --git a/apps/e2e/tests/achievements-certificates/TODO.md b/apps/e2e/tests/achievements-certificates/TODO.md deleted file mode 100644 index d36e8ad..0000000 --- a/apps/e2e/tests/achievements-certificates/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -Not converted in this pass. Source doc: `~/Downloads/Client_Achievements_and_Certificates_Test_Case_Documentation.txt`. -`test-cert-dialog.cjs` exists in chibistar/automation/starr and covers part of this — worth starting from there. diff --git a/apps/e2e/tests/advertisements/admin.spec.js b/apps/e2e/tests/advertisements/admin.spec.js deleted file mode 100644 index 14b6631..0000000 --- a/apps/e2e/tests/advertisements/admin.spec.js +++ /dev/null @@ -1,166 +0,0 @@ -// Ported from chibistar/automation/starr/test-advertisements-doc.cjs — see -// ~/Downloads/ADMIN_Advertisements_Test_Case_Documentation.txt. -// -// Not ported here (dropped, not silently): TC-ADV-013/014/015/016/017/018 -// (archived-table sort/filter/refresh/export/columns — generic DataTable -// behavior already covered structurally by the Courses admin spec's -// equivalent checks) and test-client-advertisements-live.cjs / -// test-ad-image-select.cjs, both hardcoded against specific live-site rows -// (a real advertisement_id, a real course_id, user_id 132's live DB state) -// that don't exist on a fresh local dev DB and can't be faithfully -// recreated without fabricating the exact scenario they were checking. -const { test, expect } = require('@playwright/test'); -const { APP_URL, USERS } = require('../../helpers/env'); -const { 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); -} - -const TS = Date.now().toString().slice(-6); -const QA_HEADLINE = `[QA-TEST] Advertisements Doc Pass ${TS}`; -const QA_HEADLINE_EDITED = `${QA_HEADLINE} (edited)`; - -test.describe.serial('Advertisements (admin)', () => { - test.setTimeout(120_000); - - let adminCookies; - - test.beforeAll(async () => { - test.setTimeout(60_000); - const { refreshToken } = await generateClientSession(USERS.admin.email); - adminCookies = buildCookies(refreshToken); - }); - - test.afterAll(async () => { - const removed = await cleanupQaTestFixtures(); - console.log('QA-TEST fixtures removed:', removed); - }); - - test.beforeEach(async ({ page }) => { - await page.context().addCookies(adminCookies); - }); - - test('TC-ADV-006/007/008: New Advertisement wizard blocks missing placement, happy-path create as Draft', async ({ page }) => { - await page.goto(`${APP_URL}/admin/advertisements/add`, { waitUntil: 'networkidle' }); - await settle(page, 1000); - await expect(page.getByText('Choose where this ad appears')).toBeVisible(); - - // TC-ADV-007: missing placement blocks Next - await page.getByRole('button', { name: 'Next' }).click(); - await page.waitForTimeout(1200); - await expect(page.getByText('Choose where this ad appears')).toBeVisible(); - - // TC-ADV-008: happy path - await page.locator('button').filter({ hasText: 'Select a placement' }).click(); - await page.getByRole('option', { name: /Tier Plans/ }).click(); - await page.waitForTimeout(400); - await page.getByRole('button', { name: 'Next' }).click(); - await page.waitForTimeout(400); - - await page.getByText('Content + image', { exact: false }).click(); - await page.getByPlaceholder('e.g. Advertisement').fill('QA-TEST Badge'); - await page.getByPlaceholder(/Discover the World/).fill(QA_HEADLINE); - await page.getByPlaceholder('Supporting text under the headline').fill('QA-TEST description for the Advertisements doc pass.'); - await page.getByRole('button', { name: 'Next' }).click(); - await page.waitForTimeout(400); - - if (await page.getByText('build an internal landing page').isVisible().catch(() => false)) { - await page.getByPlaceholder('e.g. Why upgrade to Pro').fill('QA-TEST Landing Page'); - await page.getByPlaceholder('Short summary shown under the title').fill('QA-TEST landing summary.'); - await page.getByPlaceholder('Main page content').fill('QA-TEST landing body content.'); - await page.getByRole('button', { name: 'Next' }).click(); - await page.waitForTimeout(400); - } - - const activeSwitch = page.locator('button[role="switch"]'); - if (await activeSwitch.isVisible().catch(() => false)) { - if ((await activeSwitch.getAttribute('aria-checked')) === 'true') await activeSwitch.click(); // force Draft - } - await page.waitForTimeout(300); - await page.getByRole('button', { name: 'Next' }).click(); - await page.waitForTimeout(400); - - await expect(page.getByText(QA_HEADLINE)).toBeVisible(); - await page.getByRole('button', { name: 'Create advertisement' }).click(); - await page.waitForURL('**/admin/advertisements', { timeout: 15000 }); - await settle(page, 1500); - await expect(page.getByText(QA_HEADLINE).first()).toBeVisible(); - }); - - test('TC-ADV-002/009: view then edit the advertisement', async ({ page }) => { - await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' }); - await settle(page); - await page.getByText(QA_HEADLINE).first().click(); - await page.waitForURL('**/admin/advertisements/*/view', { timeout: 10000 }); - await settle(page, 800); - await expect(page.getByRole('heading', { name: QA_HEADLINE })).toBeVisible(); - - await page.getByRole('button', { name: 'Edit' }).click(); - await page.waitForURL('**/admin/advertisements/*/edit', { timeout: 10000 }); - await settle(page, 800); - const hInput = page.getByPlaceholder(/Discover the World/); - await hInput.fill(''); - await hInput.fill(QA_HEADLINE_EDITED); - await page.getByRole('button', { name: /save changes/i }).click(); - await page.waitForTimeout(1200); - await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - await expect(page.getByText(QA_HEADLINE_EDITED).first()).toBeVisible(); - }); - - test('TC-ADV-010/011/012: archive, appears in Archived, restore back to active', async ({ page }) => { - await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' }); - await settle(page); - const qaCard = page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED }).first(); - await qaCard.getByLabel('Delete').click(); - await page.getByText('will be moved to archived advertisements').waitFor({ timeout: 8000 }); - await expect(page.getByText('QA-TEST', { exact: false })).toBeVisible(); - await page.getByRole('button', { name: 'Archive' }).click(); - await settle(page, 2500); - await expect(page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED })).toHaveCount(0); - - await page.getByRole('button', { name: 'Archived' }).click(); - await page.waitForURL('**/admin/advertisements/archived', { timeout: 10000 }); - await settle(page, 1200); - await expect(page.getByText(QA_HEADLINE_EDITED).first()).toBeVisible(); - - const row = page.locator('table tbody tr').filter({ hasText: QA_HEADLINE_EDITED }).first(); - await row.locator('button').last().click(); - await page.waitForTimeout(500); - await page.getByText('Restore', { exact: true }).click(); - await page.waitForTimeout(500); - await page.getByRole('button', { name: /^restore$/i }).click(); - await settle(page, 1800); - - await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' }); - await settle(page); - await expect(page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED })).toBeVisible(); - }); - - test('TC-ADV-019: archive then permanently delete (cleanup path)', async ({ page }) => { - await page.goto(`${APP_URL}/admin/advertisements`, { waitUntil: 'networkidle' }); - await settle(page); - const qaCard = page.locator('div.rounded-lg.border').filter({ hasText: QA_HEADLINE_EDITED }).first(); - await qaCard.getByLabel('Delete').click(); - await page.waitForTimeout(600); - await page.getByRole('button', { name: 'Archive' }).click(); - await settle(page, 2500); - - await page.goto(`${APP_URL}/admin/advertisements/archived`, { waitUntil: 'networkidle' }); - await settle(page, 1500); - const row = page.locator('table tbody tr').filter({ hasText: QA_HEADLINE_EDITED }).first(); - await row.locator('button').last().click(); - await page.waitForTimeout(500); - await page.getByText('Delete', { exact: true }).click(); - await page.waitForTimeout(500); - await page.getByRole('button', { name: /delete permanently/i }).click(); - await settle(page, 2000); - - await page.reload({ waitUntil: 'networkidle' }); - await settle(page, 1000); - await expect(page.getByText(QA_HEADLINE_EDITED)).toHaveCount(0); - }); -}); diff --git a/apps/e2e/tests/courses/admin.spec.js b/apps/e2e/tests/courses/admin.spec.js deleted file mode 100644 index 419aa49..0000000 --- a/apps/e2e/tests/courses/admin.spec.js +++ /dev/null @@ -1,373 +0,0 @@ -// Ported from chibistar/automation/starr/test-courses-{newcourse,archive-final, -// assessment-archive}.cjs — see ~/Downloads/Courses_Test_Case_Documentation.txt. -// -// The source scripts were written as a manually-run, multi-phase live-site -// session (phase B1 writes a JSON handoff file that phase B2 reads, some -// checks assume "an earlier interrupted run already did X", row-count -// assertions assume exactly one pre-existing live course). None of that is -// safe to rerun unattended in CI, so this rebuilds the same TC-ID coverage -// as one self-contained, idempotent run: every fixture is created fresh -// under this run's own [QA-TEST] tag and torn down in afterAll — no -// cross-script file handoff, no assumptions about what else exists in the DB. -// -// Not ported (dropped, not silently — see apps/e2e/README or the PR that -// introduces this file): TC-CRS-001..009/028/030/042/043/046 (Refresh/Export/ -// column-persistence checks tied to exact live single-row counts or file -// downloads) and TC-CRS-058 (jump-to-question, minor UI convenience). -const { test, expect } = require('@playwright/test'); -const { APP_URL, USERS } = require('../../helpers/env'); -const { 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 finishWizardFrom(page, startStep = 'rewards') { - const nextBtn = page.getByRole('button', { name: /^next$/i }); - if (startStep === 'basic') { await nextBtn.click(); await page.waitForTimeout(1000); } - await nextBtn.click(); await page.waitForTimeout(800); // roadmap -> rewards - await nextBtn.click(); await page.waitForTimeout(800); // rewards -> review - await page.getByRole('button', { name: /^finish$/i }).click(); - await page.waitForURL('**/view', { timeout: 15000 }).catch(() => {}); - await settle(page, 1500); - return page.url().match(/\/admin\/courses\/(\d+)\/view/)?.[1] ?? null; -} - -async function quickCreateCourse(page, title, code) { - await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' }); - await settle(page, 1000); - await page.locator('#title').fill(title); - await page.locator('#course_code').fill(code); - await page.getByRole('button', { name: /add objective/i }).click(); - await page.locator('input[placeholder="Objective 1"]').fill('QA automation objective.'); - await page.waitForTimeout(400); - return finishWizardFrom(page, 'basic'); -} - -async function archiveCourseByCode(page, code) { - await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - const row = page.locator('table tbody tr', { hasText: code }); - await row.locator('button').last().click(); - await page.waitForTimeout(400); - await page.getByText(/^archive course$/i).click(); - await page.waitForTimeout(800); - await page.getByRole('button', { name: /^archive$/i }).last().click(); - await settle(page, 1500); -} - -const TS = Date.now().toString().slice(-6); -const QA_TITLE = `[QA-TEST] Courses Automation ${TS}`; -const QA_CODE = `QA-TEST-${TS}`; -const HELPER_CODE = `QA-TEST-BULK-${TS}`; - -test.describe.serial('Courses (admin)', () => { - test.setTimeout(180_000); - let courseId; - let helperCourseId; - - let adminCookies; - - test.beforeAll(async () => { - test.setTimeout(60_000); - const { refreshToken } = await generateClientSession(USERS.admin.email); - adminCookies = buildCookies(refreshToken); - }); - - test.afterAll(async () => { - const removed = await cleanupQaTestFixtures(); - console.log('QA-TEST fixtures removed:', removed); - }); - - test.beforeEach(async ({ page }) => { - await page.context().addCookies(adminCookies); - }); - - test('TC-CRS-010/012/013/014: New Course wizard blocks submission on missing required fields', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' }); - await settle(page); - await page.getByRole('button', { name: /^new course$/i }).click(); - await page.waitForURL('**/admin/courses/add', { timeout: 10000 }); - await settle(page, 1200); - await expect(page.locator('text=Basic Information')).toBeVisible(); - - const nextBtn = page.getByRole('button', { name: /^next$/i }); - - // All fields blank - await nextBtn.click(); - await page.waitForTimeout(800); - await expect(page.locator('#title')).toBeVisible(); - await expect(page.locator('p.text-destructive').first()).toBeVisible(); - - // Title only - await page.locator('#title').fill(QA_TITLE); - await nextBtn.click(); - await page.waitForTimeout(800); - await expect(page.locator('#title')).toBeVisible(); - - // Title + code, objectives still missing - await page.locator('#course_code').fill(QA_CODE); - await nextBtn.click(); - await page.waitForTimeout(800); - await expect(page.locator('#title')).toBeVisible(); - }); - - test('TC-CRS-017: unsaved-changes guard blocks navigating away from a dirty wizard', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - await page.locator('#title').fill(`${QA_TITLE} (guard check)`); - await page.getByRole('button', { name: /^cancel$/i }).click(); - await page.waitForTimeout(1000); - await expect(page.locator('[role="alertdialog"]')).toHaveCount(1); - await page.getByRole('button', { name: /stay on this page/i }).click(); - }); - - test('TC-CRS-016/018/011: step navigation retains data, achievement is radio-not-checklist, happy-path finish', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - await page.locator('#title').fill(QA_TITLE); - await page.locator('#course_code').fill(QA_CODE); - await page.getByRole('button', { name: /add objective/i }).click(); - await page.locator('input[placeholder="Objective 1"]').fill('QA automation objective.'); - - const nextBtn = page.getByRole('button', { name: /^next$/i }); - await nextBtn.click(); - await page.waitForTimeout(1200); - await page.getByText(/^basic info$/i).click(); - await page.waitForTimeout(600); - await expect(page.locator('#title')).toHaveValue(QA_TITLE); - - await nextBtn.click(); await page.waitForTimeout(800); // roadmap - await nextBtn.click(); await page.waitForTimeout(800); // -> rewards - - const wantsBtn = page.getByRole('button', { name: /^yes, add one$/i }); - if (await wantsBtn.count()) await wantsBtn.click(); - await page.waitForTimeout(400); - - await page.getByRole('button', { name: /new achievement/i }).click(); - await page.waitForTimeout(400); - await page.locator('#ach_key').fill(`qa_test_ach_${TS}_a`); - await page.locator('#ach_label').fill('QA Test Achievement A'); - await page.getByRole('button', { name: /^create achievement$/i }).click(); - await page.waitForTimeout(1000); - - await page.getByRole('button', { name: /new achievement/i }).click(); - await page.waitForTimeout(400); - await page.locator('#ach_key').fill(`qa_test_ach_${TS}_b`); - await page.locator('#ach_label').fill('QA Test Achievement B'); - await page.getByRole('button', { name: /^create achievement$/i }).click(); - await page.waitForTimeout(1000); - - await expect(page.getByText('QA Test Achievement A')).toHaveCount(0); - await expect(page.getByText('QA Test Achievement B')).toBeVisible(); - - courseId = await finishWizardFrom(page, 'rewards'); - expect(courseId).toBeTruthy(); - }); - - test('TC-CRS-015: duplicate course_code is blocked', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses/add`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - await page.locator('#title').fill(`[QA-TEST] Duplicate Code Check ${TS}`); - await page.locator('#course_code').fill(QA_CODE); // reuse the same code - await page.getByRole('button', { name: /add objective/i }).click(); - await page.locator('input[placeholder="Objective 1"]').fill('QA duplicate-code check objective.'); - await page.waitForTimeout(400); - const dupCourseId = await finishWizardFrom(page, 'basic'); - expect(dupCourseId).toBeNull(); - }); - - test('TC-CRS-039/040/041/044: Columns toggle hide/reshow + sort', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' }); - await settle(page); - await page.getByRole('button', { name: /^columns$/i }).click(); - await page.waitForTimeout(400); - const toggleItems = await page.locator('[role="menuitemcheckbox"]').count(); - expect(toggleItems).toBeGreaterThan(1); - - await page.locator('[role="menuitemcheckbox"]', { hasText: 'Level' }).click(); - await page.waitForTimeout(400); - await page.keyboard.press('Escape'); - await expect(page.locator('th', { hasText: 'Level' })).toHaveCount(0); - - await page.getByRole('button', { name: /^columns$/i }).click(); - await page.waitForTimeout(300); - await page.locator('[role="menuitemcheckbox"]', { hasText: 'Level' }).click(); - await page.waitForTimeout(400); - await page.keyboard.press('Escape'); - await expect(page.locator('th', { hasText: 'Level' })).toHaveCount(1); - - const titleHeader = page.locator('th', { hasText: /^Title$/ }).first(); - await titleHeader.locator('button').first().click(); - await page.waitForTimeout(200); - await page.getByText(/^sort asc$/i).click(); - await page.waitForTimeout(600); - await expect(titleHeader.locator('svg')).toHaveCount(1, { timeout: 3000 }).catch(() => {}); - }); - - test('TC-CRS-045/047/048/049/050: View Info renders details, 404 for bad id, Edit navigation', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' }); - await settle(page); - const row = page.locator('table tbody tr', { hasText: QA_CODE }); - await row.locator('button').last().click(); - await page.waitForTimeout(300); - await page.getByText(/^view info$/i).click(); - await page.waitForURL('**/view', { timeout: 10000 }); - await settle(page, 1200); - await expect(page.getByText('Basic Information')).toBeVisible(); - - await page.goto(`${APP_URL}/admin/courses/999999999/view`, { waitUntil: 'networkidle' }); - await page.waitForTimeout(3000); - await expect(page.getByText(/course not found/i)).toBeVisible(); - - await page.goto(`${APP_URL}/admin/courses/${courseId}/view`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - await page.getByRole('button', { name: /edit course/i }).first().click(); - await page.waitForURL('**/edit', { timeout: 8000 }); - expect(page.url()).toContain('/edit'); - }); - - test('TC-CRS-055/056/057/053/054/059/060: assessment builder validation', async ({ page }) => { - await page.goto(`${APP_URL}/admin/courses/${courseId}/assessment`, { waitUntil: 'networkidle' }); - await settle(page, 2500); - - await page.getByRole('button', { name: /^multi select$/i }).click(); - await page.waitForTimeout(600); - await page.getByRole('button', { name: /^save assessment$/i }).click(); - await page.waitForTimeout(800); - await expect(page.getByText(/question text is required/i)).toBeVisible(); - - const card = page.locator('.rounded-xl.border.bg-card').last(); - await card.locator('textarea[placeholder*="question here" i]').fill('QA automation multi-select test question.'); - const optionInputs = card.locator('input[placeholder^="Option "]'); - const optCount = await optionInputs.count(); - for (let i = 0; i < optCount; i++) await optionInputs.nth(i).fill(`QA option ${i + 1}`); - let incorrectToggles = card.locator('button[title="Mark as incorrect"]'); - while (await incorrectToggles.count() > 0) { - await incorrectToggles.first().click(); - await page.waitForTimeout(200); - } - await page.getByRole('button', { name: /^save assessment$/i }).click(); - await page.waitForTimeout(800); - await expect(page.getByText(/at least one correct answer is required/i)).toBeVisible(); - - await card.locator('button[title="Mark as correct"]').first().click(); - await page.waitForTimeout(200); - await optionInputs.nth(optCount - 1).fill(''); - await page.getByRole('button', { name: /^save assessment$/i }).click(); - await page.waitForTimeout(800); - await expect(page.getByText(/all option texts are required/i)).toBeVisible(); - - await optionInputs.nth(optCount - 1).fill(`QA option ${optCount}`); - await page.getByRole('button', { name: /^save assessment$/i }).click(); - await page.waitForTimeout(1500); - await expect(page.locator('p.text-destructive')).toHaveCount(0); - - const dupStatus = await page.evaluate(async (id) => { - const res = await fetch(`/api/admin/courses/${id}/assessment`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify({ title: 'Duplicate attempt' }), - }); - return res.status; - }, courseId); - expect(dupStatus).toBe(409); - - await page.goto(`${APP_URL}/admin/courses/${courseId}/assessment`, { waitUntil: 'networkidle' }); - await settle(page, 2000); - const timeLimitVal = await page.locator('input[placeholder="No limit"]').inputValue().catch(() => null); - expect(timeLimitVal === '' || timeLimitVal === null).toBe(true); - - await page.locator('input[placeholder="Course Assessment"]').fill(`[QA-TEST] Assessment dirty-${TS}`).catch(() => {}); - await page.waitForTimeout(400); - await page.goBack(); - await page.waitForTimeout(1000); - await expect(page.locator('[role="alertdialog"]')).toHaveCount(1); - }); - - test('TC-CRS-029/031/032/033/034/035/036/037/038: archive / restore / bulk-restore / delete lifecycle', async ({ page }) => { - helperCourseId = await quickCreateCourse(page, '[QA-TEST] Bulk Restore Helper', HELPER_CODE); - expect(helperCourseId).toBeTruthy(); - - await archiveCourseByCode(page, QA_CODE); - await archiveCourseByCode(page, HELPER_CODE); - - await page.goto(`${APP_URL}/admin/courses/archived`, { waitUntil: 'networkidle' }); - await settle(page, 1500); - const archivedRows = page.locator('table tbody tr'); - await expect(archivedRows).not.toHaveCount(0); - await expect(page.getByText(QA_CODE)).toBeVisible(); - await expect(page.getByText(HELPER_CODE)).toBeVisible(); - - // Kebab on an archived row: exactly Restore + Delete - await archivedRows.first().locator('button').last().click(); - await page.waitForTimeout(400); - const menuItems = await page.locator('[role="menuitem"]').allInnerTexts(); - expect(menuItems.length).toBe(2); - expect(menuItems.some((t) => /restore/i.test(t))).toBe(true); - expect(menuItems.some((t) => /delete/i.test(t))).toBe(true); - await page.keyboard.press('Escape'); - - // Cancel a restore — row count unchanged - const beforeCancel = await archivedRows.count(); - await archivedRows.first().locator('button').last().click(); - await page.waitForTimeout(400); - await page.getByText(/^restore$/i).click(); - await page.waitForTimeout(600); - await page.getByRole('button', { name: /^cancel$/i }).click(); - await settle(page, 1200); - await expect(archivedRows).toHaveCount(beforeCancel); - - // Restore one, confirm active - await archivedRows.first().locator('button').last().click(); - await page.waitForTimeout(400); - await page.getByText(/^restore$/i).click(); - await page.waitForTimeout(600); - await page.getByRole('button', { name: /^restore$/i }).last().click(); - await settle(page, 1800); - await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - const activeQaCountAfterSingleRestore = await page.locator('table tbody tr', { hasText: /QA-TEST/i }).count(); - expect(activeQaCountAfterSingleRestore).toBeGreaterThanOrEqual(1); - - // Re-archive it, then bulk-restore both - const reArchivedCode = (await page.locator('table tbody tr', { hasText: /QA-TEST/i }).first().innerText()).includes(QA_CODE) ? QA_CODE : HELPER_CODE; - await archiveCourseByCode(page, reArchivedCode); - await page.goto(`${APP_URL}/admin/courses/archived`, { waitUntil: 'networkidle' }); - await settle(page, 1500); - const checkboxes = page.locator('table tbody tr button[role="checkbox"]'); - const cbCount = await checkboxes.count(); - for (let i = 0; i < cbCount; i++) { await checkboxes.nth(i).click(); await page.waitForTimeout(150); } - await page.getByRole('button', { name: /^restore$/i }).first().click(); - await page.waitForTimeout(600); - await page.getByRole('button', { name: /^restore/i }).last().click(); - await settle(page, 1800); - await page.goto(`${APP_URL}/admin/courses`, { waitUntil: 'networkidle' }); - await settle(page, 1200); - await expect(page.getByText(QA_CODE)).toBeVisible(); - await expect(page.getByText(HELPER_CODE)).toBeVisible(); - - // Archive both again, then permanently delete each - await archiveCourseByCode(page, QA_CODE); - await archiveCourseByCode(page, HELPER_CODE); - await page.goto(`${APP_URL}/admin/courses/archived`, { waitUntil: 'networkidle' }); - await settle(page, 1500); - let remaining = await page.locator('table tbody tr', { hasText: /QA-TEST/i }).count(); - expect(remaining).toBeGreaterThanOrEqual(2); - while (remaining > 0) { - const row = page.locator('table tbody tr', { hasText: /QA-TEST/i }).first(); - await row.locator('button').last().click(); - await page.waitForTimeout(400); - await page.getByText(/^delete$/i).click(); - await page.waitForTimeout(800); - await expect(page.locator('[role="alertdialog"]')).toHaveCount(1); - await page.getByRole('button', { name: /delete.*permanently/i }).click(); - await settle(page, 1500); - remaining = await page.locator('table tbody tr', { hasText: /QA-TEST/i }).count(); - } - await page.reload({ waitUntil: 'networkidle' }); - await settle(page, 1200); - await expect(page.locator('table tbody tr', { hasText: /QA-TEST/i })).toHaveCount(0); - }); -}); diff --git a/apps/e2e/tests/dashboard/TODO.md b/apps/e2e/tests/dashboard/TODO.md deleted file mode 100644 index d4034c1..0000000 --- a/apps/e2e/tests/dashboard/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -Not converted in this pass. Source doc: `~/Downloads/Client_Dashboard_Test_Case_Documentation.txt`. -No existing chibistar/automation/starr script to port from — would need to be written from scratch against the doc. diff --git a/apps/e2e/tests/notifications/TODO.md b/apps/e2e/tests/notifications/TODO.md deleted file mode 100644 index 86a843c..0000000 --- a/apps/e2e/tests/notifications/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -Not converted in this pass. Source doc: `~/Downloads/Client_Notifications_Test_Case_Documentation.txt`. -No existing chibistar/automation/starr script to port from — would need to be written from scratch against the doc. diff --git a/apps/e2e/tests/profile-account/TODO.md b/apps/e2e/tests/profile-account/TODO.md deleted file mode 100644 index ca7c869..0000000 --- a/apps/e2e/tests/profile-account/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -Not converted in this pass. Source doc: `~/Downloads/Client_Profile_and_Account_Test_Case_Documentation.txt`. -No existing chibistar/automation/starr script to port from — would need to be written from scratch against the doc. diff --git a/apps/e2e/tests/tasks/TODO.md b/apps/e2e/tests/tasks/TODO.md deleted file mode 100644 index ffec3f3..0000000 --- a/apps/e2e/tests/tasks/TODO.md +++ /dev/null @@ -1,11 +0,0 @@ -Not converted in this pass — scope-reduced under time constraints, not silently dropped. - -Source doc: `~/Downloads/ADMIN_Tasks_Test_Case_Documentation.txt`. -Source scripts to port from (chibistar/automation/starr/): `test-tasks-admin.cjs` (589 lines), -`test-tasks-admin-part2.cjs` (308 lines), `test-tasks-admin-part3.cjs` (326 lines), -`test-tasks-admin-part4.cjs` (162 lines) — ~1400 lines total, largest of the five modules -originally scoped for this pass. Follow the same conversion pattern used in -`../courses/admin.spec.js` and `../advertisements/admin.spec.js`: rebuild as one -self-contained `test.describe.serial` spec using fresh `[QA-TEST]`-tagged fixtures -per run (the source scripts assume specific pre-existing live state / prior-run -leftovers, which isn't safe to replay unattended in CI). diff --git a/apps/e2e/tests/tier-plans/TODO.md b/apps/e2e/tests/tier-plans/TODO.md deleted file mode 100644 index 73ff005..0000000 --- a/apps/e2e/tests/tier-plans/TODO.md +++ /dev/null @@ -1,11 +0,0 @@ -Not converted in this pass — scope-reduced under time constraints, not silently dropped. - -Source docs: `~/Downloads/ADMIN_Tier_Plans_Test_Case_Documentation.txt` and -`~/Downloads/Client_Tier_Plans_Test_Case_Documentation.txt`. -Source scripts to port from (chibistar/automation/starr/): `test-tier-plans-admin.cjs` (417 lines), -`test-tier-plans-e2e.cjs` (520 lines), `test-tier-plan-inactive.cjs` (171 lines), -`test-client-tier-plans-live.cjs` (164 lines) — ~1270 lines total. Follow the same -conversion pattern used in `../courses/admin.spec.js` and `../advertisements/admin.spec.js`. -Note `test-client-tier-plans-live.cjs` targets the LIVE_URL and hardcodes real -live user_ids/plan state — will need the same "rebuild against localhost with -fresh fixtures" treatment as the other client-side specs, not a literal port. diff --git a/apps/e2e/tests/units-lessons/client.spec.js b/apps/e2e/tests/units-lessons/client.spec.js deleted file mode 100644 index 350c3b9..0000000 --- a/apps/e2e/tests/units-lessons/client.spec.js +++ /dev/null @@ -1,360 +0,0 @@ -// 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(); - }); -}); diff --git a/package.json b/package.json index 14a95a5..78190d5 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "dev": "concurrently -n api,web -c blue,green \"pnpm --filter api dev\" \"pnpm --filter web dev\"", "build": "pnpm --filter web build", "start": "pnpm --filter api start", - "test": "pnpm --filter api test", - "test:ci": "pnpm --filter e2e test" + "test": "pnpm --filter api test" }, "devDependencies": { "concurrently": "^9.2.1"