diff --git a/.github/workflows/pr-playwright.yml b/.github/workflows/pr-playwright.yml index 46f6f1de915..42bafad7a36 100644 --- a/.github/workflows/pr-playwright.yml +++ b/.github/workflows/pr-playwright.yml @@ -133,12 +133,27 @@ jobs: run: yarn install --frozen-lockfile # Watchers are disabled for MIX_ENV=ci_e2e (config/ci_e2e.exs), - # so the JS/CSS bundles the app serves must be built once, upfront, - # rather than relying on the dev watcher process to build them live. + # so the assets the app serves must be built once, upfront, rather + # than relying on the dev watcher processes to build them live. + # Webpack builds the JS bundles here; the Tailwind CSS is built by + # Mix in the next step. - name: Build frontend assets working-directory: assets run: yarn run deploy + # Writes priv/static/css/app.css, which every layout loads. Without + # it, pages render unstyled and responsive (md:hidden, etc.) markup + # stays visible. + - name: Build Tailwind CSS + run: set -a && source oli.env && mix tailwind default --minify + + # Adaptive lesson rules are evaluated server-side by Node.js + # (priv/node/rules.js and eval.js, as built in the Dockerfile). + # Without these bundles every adaptive "Check" fails with a 500. + - name: Build server-side evaluation bundles + working-directory: assets + run: yarn run deploy-node + - name: Install Playwright dependencies working-directory: assets/automation run: npm ci @@ -167,9 +182,11 @@ jobs: cat /tmp/torus-server.log exit 1 + # Spec files run in parallel across workers; tests within a file stay + # serial because playwright.config.ts keeps fullyParallel off. - name: Run @pr-tagged Playwright tests working-directory: assets/automation - run: npx playwright test --grep @pr --reporter=line + run: npx playwright test --grep @pr --reporter=line --workers=2 - name: Upload Playwright report if: failure() diff --git a/assets/automation/package.json b/assets/automation/package.json index 706fb6bbb44..ab9c7b12659 100644 --- a/assets/automation/package.json +++ b/assets/automation/package.json @@ -10,6 +10,7 @@ "test-oauth-logins": "npm run pw oauth-login.spec.ts", "test-useraccounts": "npm run pw user-accounts.spec", "test-account-linking-smoke": "npm run pw user-accounts.spec -- --grep @account-linking", + "test-simple-author": "npm run pw simple-author.spec.ts", "test-all": "npm run pw user-accounts.spec course-authoring.spec.ts", "show-report": "npx playwright show-report", "lint": "eslint .", diff --git a/assets/automation/src/core/fixture/my-fixture.ts b/assets/automation/src/core/fixture/my-fixture.ts index 3fef59c82cb..7f886c4157b 100644 --- a/assets/automation/src/core/fixture/my-fixture.ts +++ b/assets/automation/src/core/fixture/my-fixture.ts @@ -10,6 +10,7 @@ import { AccountLinkingTask } from '@tasks/AccountLinkingTask'; import { CurriculumTask } from '@tasks/CurriculumTask'; import { HomeTask } from '@tasks/HomeTask'; import { ProjectTask } from '@tasks/ProjectTask'; +import { SimpleAuthorTask } from '@tasks/SimpleAuthorTask'; import { StudentTask } from '@tasks/StudentTask'; import { getBaseUrl, @@ -27,6 +28,7 @@ type MyFixtures = { curriculumTask: CurriculumTask; homeTask: HomeTask; projectTask: ProjectTask; + simpleAuthorTask: SimpleAuthorTask; studentTask: StudentTask; seedScenario: ( relativePath: string, @@ -88,6 +90,12 @@ export const test = base.extend({ }, { title: '📂 Project Task' }, ], + simpleAuthorTask: [ + async ({ page }, use) => { + await use(new SimpleAuthorTask(page)); + }, + { title: '🧩 Simple Author Task' }, + ], studentTask: [ async ({ page }, use) => { await use(new StudentTask(page)); diff --git a/assets/automation/src/systems/torus/pom/course/StudentCoursePO.ts b/assets/automation/src/systems/torus/pom/course/StudentCoursePO.ts index d1041563998..9c1d07b6bf9 100644 --- a/assets/automation/src/systems/torus/pom/course/StudentCoursePO.ts +++ b/assets/automation/src/systems/torus/pom/course/StudentCoursePO.ts @@ -88,7 +88,7 @@ export class StudentCoursePO { } } - await Verifier.expectIsVisible(this.page.getByRole('heading', { name: pageName, exact: true })); + await Verifier.expectIsVisible(this.openedPageTitle(pageName)); } async openFirstPage(pageName: string) { @@ -105,15 +105,28 @@ export class StudentCoursePO { startCourse.click(), ]); - await Verifier.expectIsVisible( - this.page.getByRole('heading', { name: pageName, exact: true }), - ); + await Verifier.expectIsVisible(this.openedPageTitle(pageName)); return; } await this.openPage(pageName); } + /** + * The title of an opened page. Basic pages render it as a heading. The adaptive + * lesson header renders it as plain text after a screen-reader "Title:" prefix. + */ + private openedPageTitle(pageName: string) { + const adaptiveHeaderTitle = this.page + .locator('#delivery-header .lessonTitle') + .filter({ hasText: new RegExp(`(^|\\s)${escapeRegExp(pageName)}$`) }); + + return this.page + .getByRole('heading', { name: pageName, exact: true }) + .or(adaptiveHeaderTitle) + .first(); + } + private galleryTitle(pageName: string) { return this.page.getByRole('heading', { name: pageName, @@ -277,3 +290,7 @@ export class StudentCoursePO { function isStudentLessonPath(pathname: string) { return pathname.includes('/lesson/') || pathname.includes('/adaptive_lesson/'); } + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/assets/automation/src/systems/torus/pom/delivery/AdaptiveDeckPO.ts b/assets/automation/src/systems/torus/pom/delivery/AdaptiveDeckPO.ts index 22633687bcc..98cd93e62b1 100644 --- a/assets/automation/src/systems/torus/pom/delivery/AdaptiveDeckPO.ts +++ b/assets/automation/src/systems/torus/pom/delivery/AdaptiveDeckPO.ts @@ -112,6 +112,15 @@ export class AdaptiveDeckPO { .catch(() => ''); } + /** + * The deck's single footer control, including while it is disabled during a + * check. Its label follows the screen state (the screen's check label, then + * the feedback's button label). + */ + footerButton(): Locator { + return this.page.locator('.checkContainer .buttonContainer button').first(); + } + // ------------------------------------------------------------ navigation /** diff --git a/assets/automation/src/systems/torus/pom/page/BasicPracticePagePO.ts b/assets/automation/src/systems/torus/pom/page/BasicPracticePagePO.ts index eb8e52b3874..8b757ec4487 100644 --- a/assets/automation/src/systems/torus/pom/page/BasicPracticePagePO.ts +++ b/assets/automation/src/systems/torus/pom/page/BasicPracticePagePO.ts @@ -164,21 +164,7 @@ export class BasicPracticePagePO { async ensureSimpleAuthorReady() { await this.completeSimpleAuthorOnboardingIfPresent(); - - const hasToggle = (await this.adaptiveReadOnlyInput.count().catch(() => 0)) > 0; - if (hasToggle && (await this.adaptiveReadOnlyInput.isChecked().catch(() => false))) { - await expect(this.adaptiveReadOnlyInput).toBeEnabled({ timeout: 30000 }); - - await this.page.evaluate(() => { - const input = document.querySelector('input[name="adaptive_read_only"]'); - - if (input?.checked) { - input.click(); - } - }); - - await expect(this.adaptiveReadOnlyInput).not.toBeChecked({ timeout: 10000 }); - } + await this.disableAdaptiveReadOnly(); await this.adaptiveAuthorToolbar.first().waitFor({ state: 'visible', timeout: 30000 }); await expect( @@ -192,6 +178,26 @@ export class BasicPracticePagePO { }); } + /** Switches a reopened adaptive lesson out of read-only mode when it loads locked. */ + async disableAdaptiveReadOnly() { + const hasToggle = (await this.adaptiveReadOnlyInput.count().catch(() => 0)) > 0; + if (!hasToggle) return; + + // The toggle reflects the lock state only once it is enabled. + await expect(this.adaptiveReadOnlyInput).toBeEnabled({ timeout: 30000 }); + if (!(await this.adaptiveReadOnlyInput.isChecked().catch(() => false))) return; + + await this.page.evaluate(() => { + const input = document.querySelector('input[name="adaptive_read_only"]'); + + if (input?.checked) { + input.click(); + } + }); + + await expect(this.adaptiveReadOnlyInput).not.toBeChecked({ timeout: 10000 }); + } + private async assertAdvancedAuthorEditable() { await expect(this.editTitleButton, 'Advanced authoring should be editable.').toBeEnabled({ timeout: 15000, diff --git a/assets/automation/src/systems/torus/pom/page/SimpleAuthorPO.ts b/assets/automation/src/systems/torus/pom/page/SimpleAuthorPO.ts new file mode 100644 index 00000000000..a123a16dbc1 --- /dev/null +++ b/assets/automation/src/systems/torus/pom/page/SimpleAuthorPO.ts @@ -0,0 +1,642 @@ +import { expect, Locator, Page, Request } from '@playwright/test'; +import { BasicPracticePagePO } from '@pom/page/BasicPracticePagePO'; + +export type SimpleAuthorScreenType = + | 'Instructional Screen' + | 'Multiple Choice' + | 'Multiline Text' + | 'Slider' + | 'Number Input' + | 'Text Input' + | 'Dropdown' + | 'Hub and Spoke'; + +/** Toolbar slugs; each part renders as a custom element named after the slug with `-` for `_`. */ +export type SimpleAuthorComponent = 'janus_image' | 'janus_video'; + +export type SimpleAuthorTab = 'Lesson' | 'Screen' | 'Component'; + +export type SimpleAuthorPartWidth = '100%' | '50% left' | '50% right'; + +export type SimpleAuthorPathType = 'Always' | 'Correct' | 'Any Incorrect'; + +/** A flowchart path type and, when the type navigates, its destination screen title. */ +export type SimpleAuthorPathRule = [SimpleAuthorPathType, string?]; + +export type SliderFeedbackOperator = + | 'Equal to' + | 'Between two values' + | 'Greater Than' + | 'Greater Than or Equal' + | 'Less Than' + | 'Less Than or Equal'; + +const SAVE_REQUEST = + /\/api\/v1\/(storage\/project\/[^/]+\/resource|project\/[^/]+\/(resource|activity))/; +// Longer than the editor's 500ms save debounce, so a pending write always shows up. +const SAVE_QUIET_MS = 800; + +/** + * Page object for the adaptive Simple Author (flowchart-mode) editor. + * + * Simple Author has no "All changes saved" indicator: screen edits are + * debounced and persisted through `/api/v1/storage/...` and page edits + * through `/api/v1/project/.../resource/...`. `waitForSaves` tracks those + * requests so callers can reload only after every pending write settled. + * + * Property-panel inputs carry generated ids such as + * `component_part___custom_`; the stable part is the + * field suffix, so fields are located by `[id$="_"]` inside the + * active tab. + */ +export class SimpleAuthorPO { + private readonly pendingSaves = new Set(); + private lastSaveActivity = 0; + + private readonly stage: Locator; + private readonly componentToolbar: Locator; + private readonly screenList: Locator; + private readonly rightPanel: Locator; + private readonly flowchartSidebar: Locator; + private readonly flowchartNodes: Locator; + private readonly pagePO: BasicPracticePagePO; + + constructor(private readonly page: Page) { + this.pagePO = new BasicPracticePagePO(page); + this.stage = page.locator('section.aa-stage'); + this.componentToolbar = page.locator('#advanced-authoring .component-toolbar'); + this.screenList = page.locator('.screen-list-container ul.screen-list'); + this.rightPanel = page.locator('.fixed-right-panel'); + this.flowchartSidebar = page.locator('.flowchart-sidebar'); + this.flowchartNodes = page.locator('.flowchart-node'); + + page.on('request', (request) => { + if (this.isSaveRequest(request)) { + this.pendingSaves.add(request); + this.lastSaveActivity = Date.now(); + } + }); + const settle = (request: Request) => { + if (this.pendingSaves.delete(request)) this.lastSaveActivity = Date.now(); + }; + page.on('requestfinished', settle); + page.on('requestfailed', settle); + } + + // ------------------------------------------------------------ persistence + + /** + * Waits until no Simple Author write is in flight and none started for a quiet + * window. The window is measured from the call too, so a write still inside + * the editor's 500ms save debounce is not missed. + */ + async waitForSaves(timeout = 30_000) { + const calledAt = Date.now(); + const deadline = calledAt + timeout; + + while (Date.now() < deadline) { + const quietFor = Date.now() - Math.max(this.lastSaveActivity, calledAt); + if (this.pendingSaves.size === 0 && quietFor >= SAVE_QUIET_MS) return; + await this.page.waitForTimeout(250); + } + + throw new Error( + `Simple Author saves did not settle within ${timeout}ms (${this.pendingSaves.size} pending)`, + ); + } + + /** Reloads the editor after pending writes settle and returns to the Screen Panel. */ + async reloadEditor() { + await this.waitForSaves(); + await this.page.reload(); + await this.waitForEditorLoaded(); + } + + /** + * Waits for the editor and leaves it editable: a reopened lesson can start in + * read-only mode. Once the editor header renders, the onboarding wizard is not + * showing, so only the read-only toggle needs handling. + */ + async waitForEditorLoaded() { + await expect(this.modeHeader('Flowchart'), 'Simple Author editor should load').toBeVisible({ + timeout: 60_000, + }); + await this.pagePO.disableAdaptiveReadOnly(); + await this.page.waitForFunction(() => customElements.get('janus-mcq') != null, undefined, { + timeout: 30_000, + }); + } + + // ------------------------------------------------------------ modes + + async switchToScreenPanel() { + await this.modeHeader('Screen Panel').click(); + await expect(this.componentToolbar).toBeVisible({ timeout: 30_000 }); + await expect(this.screenList).toBeVisible({ timeout: 30_000 }); + } + + async switchToFlowchart() { + await this.modeHeader('Flowchart').click(); + await expect(this.flowchartNodes.first()).toBeVisible({ timeout: 30_000 }); + } + + private modeHeader(name: 'Flowchart' | 'Screen Panel') { + return this.page + .locator('.sidebar-header') + .filter({ has: this.page.locator('.title', { hasText: new RegExp(`^${name}$`) }) }) + .first(); + } + + // ------------------------------------------------------------ screens + + /** + * Adds a screen from the Screen Panel "Add new screen" dialog; the new screen + * becomes active. + * + * Creating the first screen after a load can re-render the editor and drop the + * dialog without creating anything. The dialog flow is retried only when no + * screen-creation request went out, so a creation in flight is never repeated. + */ + async addScreen(title: string, type: SimpleAuthorScreenType) { + const modal = this.page.locator('.add-screen-modal'); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await this.page.getByRole('button', { name: 'Add new screen' }).click(); + await expect(modal).toBeVisible(); + await modal.locator('input.title-input').fill(title); + const typeButton = modal.locator('button.screen-type', { + hasText: new RegExp(`^${type}$`), + }); + await typeButton.click(); + await expect(typeButton).toHaveClass(/active/); + + const creationRequest = this.page + .waitForRequest( + (request) => + request.method() === 'POST' && + /\/api\/v1\/project\/[^/]+\/activity\//.test(request.url()), + { timeout: 5_000 }, + ) + .catch(() => null); + await modal.getByRole('button', { name: 'Next' }).click(); + if (await creationRequest) break; + + if (await modal.isVisible().catch(() => false)) await this.page.keyboard.press('Escape'); + await expect(modal).toBeHidden(); + } + + await expect(this.screenListItem(title)).toBeVisible({ timeout: 30_000 }); + await expect(modal).toBeHidden({ timeout: 30_000 }); + await this.expectActiveScreen(title); + // The new screen's initial save must land before edits, or the two + // concurrent writes can persist the template over the edit. + await this.waitForSaves(); + } + + async selectScreen(title: string) { + await this.screenListItem(title).click(); + await this.expectActiveScreen(title); + } + + private async expectActiveScreen(title: string) { + await expect(this.screenList.locator('li.active')).toHaveText(title, { timeout: 30_000 }); + await expect(this.stage.locator('oli-adaptive-authoring')).toBeVisible({ timeout: 30_000 }); + } + + private screenListItem(title: string) { + return this.screenList + .locator('li') + .filter({ hasText: new RegExp(`^${escapeRegExp(title)}$`) }); + } + + // ------------------------------------------------------------ stage parts + + part(partId: string) { + return this.stage.locator(`[id="${partId}"]`); + } + + partsOfType(type: string) { + return this.stage.locator(type); + } + + async partIds(type: string) { + return this.partsOfType(type).evaluateAll((elements) => elements.map((e) => e.id)); + } + + async selectPart(partId: string) { + const part = this.part(partId); + await expect(part).toBeVisible({ timeout: 30_000 }); + await part.click(); + await expect(this.tab('Component')).toHaveAttribute('aria-selected', 'true', { + timeout: 10_000, + }); + } + + /** + * Clicks a toolbar component button and returns the id of the part it added. + * + * Right after a screen is created the toolbar can drop the first click, so the + * click is retried, but only while the screen still has no new part. That way a + * slow add is never doubled. + */ + async addComponent(component: SimpleAuthorComponent) { + const elementType = component.replace(/_/g, '-'); + const before = await this.partIds(elementType); + const button = this.componentButton(component); + await expect(button).toBeEnabled({ timeout: 30_000 }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + await button.click(); + const added = await expect(this.partsOfType(elementType)) + .toHaveCount(before.length + 1, { timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (added) break; + } + + await expect(this.partsOfType(elementType)).toHaveCount(before.length + 1); + const after = await this.partIds(elementType); + const added = after.find((id) => !before.includes(id)); + if (!added) throw new Error(`A new ${elementType} part should be added`); + await this.waitForSaves(); + return added; + } + + private componentButton(component: SimpleAuthorComponent) { + return this.componentToolbar.locator(`button.component-button[data-component="${component}"]`); + } + + /** Opens the inline text configuration modal for a text-flow part and replaces its text. */ + async setTextFlowText(partId: string, text: string) { + await this.selectPart(partId); + await this.selectionToolbarButton('Edit').click(); + + const modal = this.page.locator('.config-modal'); + const editor = modal.locator('.ql-editor'); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await editor.click(); + await this.page.keyboard.press('ControlOrMeta+A'); + await this.page.keyboard.type(text); + await expect(editor).toHaveText(text); + await modal.getByRole('button', { name: 'Save' }).click(); + await expect(modal).toBeHidden({ timeout: 15_000 }); + await expect(this.part(partId)).toContainText(text, { timeout: 15_000 }); + await this.waitForSaves(); + } + + private selectionToolbarButton(title: 'Edit' | 'Copy') { + return this.page.locator(`.active-selection-toolbar button[title="${title}"]`).first(); + } + + // ------------------------------------------------------------ property panel + + private tab(name: SimpleAuthorTab) { + return this.rightPanel.getByRole('tab', { name, exact: true }); + } + + async openTab(name: SimpleAuthorTab) { + await this.tab(name).click(); + await expect(this.tab(name)).toHaveAttribute('aria-selected', 'true'); + } + + /** The content of the active right-panel tab. */ + tabPanel() { + return this.rightPanel.locator('.tab-pane.active'); + } + + field(suffix: string) { + return this.tabPanel().locator(`[id$="_${suffix}"]`).first(); + } + + /** + * Fills a property field, blurs it (Lesson and Screen tabs only commit on + * blur), and waits for the write: quick successive edits can otherwise + * persist an older form state over a newer one. + */ + async fillField(suffix: string, value: string) { + const input = this.field(suffix); + await expect(input).toBeVisible(); + await input.fill(value); + await input.blur(); + await expect(input).toHaveValue(value); + await this.waitForSaves(); + } + + async selectField(suffix: string, value: string) { + const select = this.field(suffix); + await expect(select).toBeVisible(); + await select.selectOption(value); + await select.blur(); + await expect(select).toHaveValue(value); + await this.waitForSaves(); + } + + async setCheckbox(suffix: string, checked: boolean) { + const checkbox = this.field(suffix); + await expect(checkbox).toBeVisible(); + await checkbox.setChecked(checked); + await expect(checkbox).toBeChecked({ checked }); + await this.waitForSaves(); + } + + async setMcqCorrectAnswer(optionLabel: string) { + await this.mcqCorrectAnswerSelect().selectOption({ label: optionLabel }); + await this.waitForSaves(); + } + + async setMcqCorrectOption(optionLabel: string, correct: boolean) { + await this.mcqCorrectAnswerCheckbox(optionLabel).setChecked(correct); + await this.waitForSaves(); + } + + async addHubSpoke(spokeLabel: string) { + await this.tabPanel().getByRole('button', { name: '+ Add Spoke' }).click(); + await expect(this.tabPanel()).toContainText(spokeLabel); + await this.waitForSaves(); + } + + /** The "Correct Answer" select of a single-selection MCQ. */ + mcqCorrectAnswerSelect() { + return this.tabPanel().locator('label:text-is("Correct Answer") + select'); + } + + /** The per-option "Correct Answer" checkboxes of a multiple-selection MCQ. */ + mcqCorrectAnswerCheckbox(optionLabel: string) { + return this.tabPanel() + .locator('label.form-label:text-is("Correct Answer") ~ div') + .filter({ hasText: new RegExp(`${escapeRegExp(optionLabel)}$`) }) + .locator('input[type="checkbox"]'); + } + + async setPartWidth(partId: string, width: SimpleAuthorPartWidth) { + await this.selectPart(partId); + const select = this.field('Size_responsiveLayoutWidth'); + await expect(select).toBeVisible(); + await select.selectOption({ label: width }); + await expect(this.responsiveItem(partId)).toHaveClass( + width === '100%' ? /full-width/ : /half-width/, + { timeout: 10_000 }, + ); + await this.waitForSaves(); + } + + private responsiveItem(partId: string) { + return this.stage.locator(`.responsive-item[data-part-id="${partId}"]`); + } + + /** Chooses a media source through the "External URL" tab of the media picker. */ + async setMediaUrl(pickerLabel: 'Select Image' | 'Select Video File', url: string) { + await this.tabPanel().getByRole('button', { name: pickerLabel }).click(); + + const modal = this.page.locator('.modal').filter({ hasText: pickerLabel }).last(); + await expect(modal).toBeVisible(); + await modal.getByRole('button', { name: 'External URL' }).click(); + const input = modal.getByPlaceholder('Enter the media URL address'); + await input.fill(url); + await modal.getByRole('button', { name: 'OK', exact: true }).click(); + await expect(modal).toBeHidden(); + await expect(this.tabPanel()).toContainText(url); + await this.waitForSaves(); + } + + sliderFeedbackRules() { + return this.tabPanel().locator('.advanced-number-feedback'); + } + + async addSliderFeedbackRule( + operator: SliderFeedbackOperator, + values: string[], + feedback: string, + ) { + const rules = this.sliderFeedbackRules(); + const count = await rules.count(); + await this.tabPanel().getByRole('button', { name: '+ Add new feedback' }).click(); + await expect(rules).toHaveCount(count + 1); + + const rule = rules.nth(count); + await rule.locator('select').selectOption({ label: operator }); + const inputs = rule.locator('input[type="number"]'); + await expect(inputs).toHaveCount(values.length); + for (let index = 0; index < values.length; index += 1) { + await inputs.nth(index).fill(values[index]); + await inputs.nth(index).blur(); + } + const textarea = rule.locator('textarea'); + await textarea.fill(feedback); + await textarea.blur(); + await this.waitForSaves(); + } + + // ------------------------------------------------------------ editing commands + + async copySelectedPart() { + await this.selectionToolbarButton('Copy').click(); + await expect(this.pasteButton()).toBeVisible(); + } + + /** The paste button only exists while a part is on the clipboard. */ + pasteButton() { + return this.overviewButtons().nth(1); + } + + async pasteWithToolbar() { + await expect(this.pasteButton()).toBeEnabled(); + await this.pasteButton().click(); + await this.waitForSaves(); + } + + /** Ctrl/Cmd+V only pastes while a stage part has focus. */ + async pasteWithKeyboard(focusPartId: string) { + await this.part(focusPartId).click(); + await this.page.keyboard.press('ControlOrMeta+v'); + } + + pasteBlockedModal() { + return this.page.locator('.modal').filter({ hasText: 'Paste Component' }); + } + + undoButton() { + return this.componentToolbar + .locator('.toolbar-column') + .filter({ has: this.page.locator('label', { hasText: /^Undo$/ }) }) + .locator('button'); + } + + redoButton() { + return this.componentToolbar + .locator('.toolbar-column') + .filter({ has: this.page.locator('label', { hasText: /^Redo$/ }) }) + .locator('button'); + } + + // ------------------------------------------------------------ scoring overview + + scoringOverviewButton() { + return this.overviewButtons().first(); + } + + scoringOverview() { + return this.page.locator('.modal').filter({ hasText: 'Scoring Overview' }); + } + + async closeScoringOverview() { + await this.scoringOverview().getByRole('button', { name: 'Close' }).first().click(); + await expect(this.scoringOverview()).toBeHidden(); + } + + private overviewButtons() { + return this.componentToolbar + .locator('.toolbar-column') + .filter({ has: this.page.locator('label', { hasText: /^Overview$/ }) }) + .locator('.toolbar-buttons > button.component-button'); + } + + // ------------------------------------------------------------ flowchart + + flowchartNode(title: string) { + return this.flowchartNodes.filter({ + has: this.page.locator('.title-text', { hasText: new RegExp(`^${escapeRegExp(title)}$`) }), + }); + } + + async selectFlowchartScreen(title: string) { + await this.flowchartNode(title) + .locator('.node-box') + .click({ position: { x: 8, y: 8 } }); + await expect(this.flowchartSidebar.locator('.screen-title')).toContainText(title, { + timeout: 10_000, + }); + } + + validationErrors() { + return this.flowchartSidebar.locator('.validation-error h3'); + } + + private paths() { + return this.flowchartSidebar.locator('.path-editor-completed, .path-editor-incomplete'); + } + + /** Opens the path at `index` and sets its type and destination. */ + private async editPath(index: number, type: SimpleAuthorPathType, destination?: string) { + const path = this.paths().nth(index); + await expect(path).toBeVisible({ timeout: 10_000 }); + if (!(await this.isEditing(path))) await path.click(); + await this.completePath(path, type, destination); + } + + /** Adds a rule; the editor opens the new path in edit mode, wherever it sorts. */ + private async addPath(type: SimpleAuthorPathType, destination?: string) { + const count = await this.paths().count(); + await this.flowchartSidebar.getByRole('button', { name: 'Add Rule' }).click(); + await expect(this.paths()).toHaveCount(count + 1); + + const newPath = this.paths().filter({ + has: this.page.getByRole('button', { name: 'Done', exact: true }), + }); + await expect(newPath).toHaveCount(1); + await this.completePath(newPath, type, destination); + } + + /** + * Sets an open path's type and destination. Screens with a single available + * path type render a label instead of a type select. + */ + private async completePath(path: Locator, type: SimpleAuthorPathType, destination?: string) { + const done = this.doneButton(path); + await expect(done).toBeVisible(); + + const typeSelect = path.locator(':scope > select'); + if ((await typeSelect.count()) > 0) { + await typeSelect.selectOption({ label: type }); + } else { + await expect(path.locator(':scope > label').first()).toHaveText(type); + } + if (destination) { + await path.locator('.destination-section select').selectOption({ label: destination }); + } + await done.click(); + await expect(this.flowchartSidebar.getByRole('button', { name: 'Done' })).toHaveCount(0); + await this.waitForSaves(); + } + + private doneButton(scope: Locator) { + return scope.getByRole('button', { name: 'Done', exact: true }); + } + + private async isEditing(path: Locator) { + return this.doneButton(path) + .isVisible() + .catch(() => false); + } + + private async deletePath(index: number) { + const count = await this.paths().count(); + const path = this.paths().nth(index); + if (!(await this.isEditing(path))) await path.click(); + await path.getByRole('button', { name: 'Delete', exact: true }).click(); + const confirm = this.page.locator('#btnDelete', { hasText: 'Delete Rule' }); + await confirm.click(); + await expect(confirm).toBeHidden(); + await expect(this.paths()).toHaveCount(count - 1); + await this.waitForSaves(); + } + + /** + * Makes a flowchart screen's outgoing paths match `rules`. Adding screens from + * the Screen Panel also adds "Unknown Rule" paths, the editor re-sorts paths + * by priority after each edit, and a screen never drops to zero paths, so + * extra paths are deleted first and each rule then claims a path that does + * not match an already configured rule. + */ + async setPaths(screenTitle: string, rules: SimpleAuthorPathRule[]) { + await this.selectFlowchartScreen(screenTitle); + while ((await this.paths().count()) > rules.length) { + await this.deletePath((await this.paths().count()) - 1); + } + + const configured: SimpleAuthorPathRule[] = []; + for (const rule of rules) { + const index = await this.firstUnconfiguredPathIndex(configured); + if (index === -1) { + await this.addPath(...rule); + } else { + await this.editPath(index, ...rule); + } + configured.push(rule); + } + + await expect(this.paths()).toHaveCount(rules.length); + for (const [type, destination] of rules) { + await expect( + this.matchingPaths(type, destination), + `${screenTitle} should have a "${type}" path${destination ? ` to ${destination}` : ''}`, + ).toHaveCount(1); + } + await this.waitForSaves(); + } + + private matchingPaths(type: SimpleAuthorPathType, destination?: string) { + // Case-sensitive patterns, so "Correct" does not match "Any Incorrect". + return this.paths() + .filter({ hasText: new RegExp(escapeRegExp(type)) }) + .filter({ hasText: new RegExp(escapeRegExp(destination ?? type)) }); + } + + private async firstUnconfiguredPathIndex(configured: SimpleAuthorPathRule[]) { + const texts = await this.paths().allInnerTexts(); + return texts.findIndex( + (text) => + !configured.some( + ([type, destination]) => text.includes(type) && text.includes(destination ?? type), + ), + ); + } + + private isSaveRequest(request: Request) { + return ['PUT', 'POST'].includes(request.method()) && SAVE_REQUEST.test(request.url()); + } +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/assets/automation/src/systems/torus/tasks/CurriculumTask.ts b/assets/automation/src/systems/torus/tasks/CurriculumTask.ts index a53d081adaf..682e3de16fb 100644 --- a/assets/automation/src/systems/torus/tasks/CurriculumTask.ts +++ b/assets/automation/src/systems/torus/tasks/CurriculumTask.ts @@ -195,7 +195,7 @@ export class CurriculumTask { } @step('Create an adaptive page in Simple Author') - async createAdaptivePageInSimpleAuthor(stayInEditor = false) { + async createAdaptivePageInSimpleAuthor(stayInEditor = false, lessonTitle?: string) { const openedEditor = await this.addPage('adaptive-simple-practice', true); if (!openedEditor) { await this.enterPage( @@ -208,9 +208,10 @@ export class CurriculumTask { await this.basicPP.ensureSimpleAuthorReady(); await this.basicPP.waitForAdvancedAuthorFlowchartReady(); await this.basicPP.waitForChangesSaved().catch(() => void 0); + if (lessonTitle) await this.basicPP.renameTitle(lessonTitle); if (!stayInEditor) { await this.returnToCurriculum(); - await this.curriculum.expectPageVisible('New Simple Author Page'); + await this.curriculum.expectPageVisible(lessonTitle ?? 'New Simple Author Page'); } } diff --git a/assets/automation/src/systems/torus/tasks/SimpleAuthorTask.ts b/assets/automation/src/systems/torus/tasks/SimpleAuthorTask.ts new file mode 100644 index 00000000000..dba75a647c8 --- /dev/null +++ b/assets/automation/src/systems/torus/tasks/SimpleAuthorTask.ts @@ -0,0 +1,54 @@ +import { Page } from '@playwright/test'; +import { step } from '@core/decoration/step'; +import { SimpleAuthorPO, SimpleAuthorScreenType } from '@pom/page/SimpleAuthorPO'; +import { CurriculumTask } from '@tasks/CurriculumTask'; +import { HomeTask } from '@tasks/HomeTask'; +import { ProjectTask } from '@tasks/ProjectTask'; + +/** + * Business workflows for the adaptive Simple Author editor. Fine-grained + * editor interactions and assertions live on `editor` (SimpleAuthorPO). + */ +export class SimpleAuthorTask { + readonly editor: SimpleAuthorPO; + private readonly homeTask: HomeTask; + private readonly projectTask: ProjectTask; + private readonly curriculumTask: CurriculumTask; + + constructor(page: Page) { + this.editor = new SimpleAuthorPO(page); + this.homeTask = new HomeTask(page); + this.projectTask = new ProjectTask(page); + this.curriculumTask = new CurriculumTask(page); + } + + /** Creates a Simple Author lesson in the project and leaves its editor open. */ + @step('Create Simple Author lesson "{lessonTitle}" in project "{projectTitle}"') + async createLesson(projectTitle: string, lessonTitle: string) { + await this.projectTask.searchAndEnterProject(projectTitle); + await this.homeTask.enterToCurriculum(); + await this.curriculumTask.createAdaptivePageInSimpleAuthor(true, lessonTitle); + await this.editor.waitForEditorLoaded(); + await this.editor.waitForSaves(); + } + + @step('Add "{type}" screen "{title}"') + async addScreen(title: string, type: SimpleAuthorScreenType) { + await this.editor.addScreen(title, type); + } + + /** Reloads the editor once every write settled and reopens the given screen. */ + @step('Reload the editor and reopen screen "{title}"') + async reloadAndOpenScreen(title: string) { + await this.editor.reloadEditor(); + await this.editor.switchToScreenPanel(); + await this.editor.selectScreen(title); + } + + @step('Publish the project') + async publish() { + await this.editor.waitForSaves(); + await this.homeTask.enterToPublish(); + await this.projectTask.publishProject(); + } +} diff --git a/assets/automation/tests/torus/student_delivery/support/image_coding_sample.png b/assets/automation/tests/resources/media_files/image-test-01.png similarity index 100% rename from assets/automation/tests/torus/student_delivery/support/image_coding_sample.png rename to assets/automation/tests/resources/media_files/image-test-01.png diff --git a/assets/automation/tests/torus/student_delivery/support/image_coding_table.csv b/assets/automation/tests/resources/media_files/table-test-01.csv similarity index 100% rename from assets/automation/tests/torus/student_delivery/support/image_coding_table.csv rename to assets/automation/tests/resources/media_files/table-test-01.csv diff --git a/assets/automation/tests/resources/readme.txt b/assets/automation/tests/resources/readme.txt index a1a7507a535..4212bdd0940 100644 --- a/assets/automation/tests/resources/readme.txt +++ b/assets/automation/tests/resources/readme.txt @@ -1 +1,3 @@ Media upload fixtures for Playwright live in this folder (`media_files/`). No .env files are required by the automation suite. + +Files in `media_files/` can also be served to the browser at `/test/support/` when Playwright scenarios are enabled. Add the file name and its content type to the allowlist in `lib/oli_web/controllers/playwright_support_asset_controller.ex` to expose a new fixture. diff --git a/assets/automation/tests/torus/course_authoring/mixed_workflow/actions.ts b/assets/automation/tests/torus/course_authoring/mixed_workflow/actions.ts index 3eeac546e91..c1ab1be205b 100644 --- a/assets/automation/tests/torus/course_authoring/mixed_workflow/actions.ts +++ b/assets/automation/tests/torus/course_authoring/mixed_workflow/actions.ts @@ -135,7 +135,7 @@ export const mixedWorkflowActions: WorkflowActionRegistry = { async author_image_workflow({ curriculumTask, homeTask, page }, params) { const projectSlug = asString(params.project_slug, 'project_slug'); const pageRevisionSlug = asString(params.page_revision_slug, 'page_revision_slug'); - const pngName = 'image_coding_sample.png'; + const pngName = 'image-test-01.png'; const jpgName = 'img-mock-05-16-2025.jpg'; const caption = 'IMAGE-D authored image caption'; const alt = 'IMAGE-E alternative text'; @@ -152,7 +152,7 @@ export const mixedWorkflowActions: WorkflowActionRegistry = { ); await uploadImage( page, - path.resolve(process.cwd(), 'tests/torus/student_delivery/support', pngName), + path.resolve(process.cwd(), 'tests/resources/media_files', pngName), pngName, ); await page.locator('.name').getByText(jpgName, { exact: true }).click(); diff --git a/assets/automation/tests/torus/course_authoring/playwright_simple_author.yaml b/assets/automation/tests/torus/course_authoring/playwright_simple_author.yaml new file mode 100644 index 00000000000..100f46e885e --- /dev/null +++ b/assets/automation/tests/torus/course_authoring/playwright_simple_author.yaml @@ -0,0 +1,56 @@ +# Seeds the Simple Author suite. +# Parameters: +# RUN_ID - suffix that keeps emails, project, and section names unique per run. +# The Test Author/Educator/Learner display names and Automation test section title +# satisfy the guarded automation teardown contract used by the companion spec. + +- user: + name: 'simple_author_author' + type: 'author' + email: 'simple-author${RUN_ID}@example.com' + given_name: 'Test' + family_name: 'Author' + password: 'changeme123456' + +- user: + name: 'simple_author_instructor' + type: 'instructor' + email: 'simple-instructor${RUN_ID}@example.com' + given_name: 'Test' + family_name: 'Educator' + password: 'changeme123456' + can_create_sections: true + +- user: + name: 'simple_author_student' + type: 'student' + email: 'simple-student${RUN_ID}@example.com' + given_name: 'Test' + family_name: 'Learner' + password: 'changeme123456' + +- project: + name: 'Simple Author Suite${RUN_ID}' + title: 'Simple Author Suite${RUN_ID}' + visibility: global + root: + children: + - page: 'Page 1' + +- section: + name: 'Simple Author Suite${RUN_ID}' + title: 'Automation test section' + from: 'Simple Author Suite${RUN_ID}' + open_and_free: true + +- enroll: + user: 'simple_author_instructor' + email: 'simple-instructor${RUN_ID}@example.com' + section: 'Simple Author Suite${RUN_ID}' + role: instructor + +- enroll: + user: 'simple_author_student' + email: 'simple-student${RUN_ID}@example.com' + section: 'Simple Author Suite${RUN_ID}' + role: student diff --git a/assets/automation/tests/torus/course_authoring/simple-author.spec.ts b/assets/automation/tests/torus/course_authoring/simple-author.spec.ts new file mode 100644 index 00000000000..a269244b2b4 --- /dev/null +++ b/assets/automation/tests/torus/course_authoring/simple-author.spec.ts @@ -0,0 +1,515 @@ +import { resetRuntimeConfig, setRuntimeConfig } from '@core/runtimeConfig'; +import { test } from '@fixture/my-fixture'; +import { StudentCoursePO } from '@pom/course/StudentCoursePO'; +import { AdaptiveDeckPO } from '@pom/delivery/AdaptiveDeckPO'; +import { SimpleAuthorPO, SimpleAuthorScreenType } from '@pom/page/SimpleAuthorPO'; +import { TYPE_USER } from '@pom/types/type-user'; +import { teardownAutomationCourse, type AutomationSetupResponse } from '@tasks/AutomationSetupTask'; +import { HomeTask } from '@tasks/HomeTask'; +import { SimpleAuthorTask } from '@tasks/SimpleAuthorTask'; +import { BrowserContext, expect, Locator, Page } from '@playwright/test'; +import path from 'node:path'; + +const runId = `-${Date.now()}-${process.pid}`; +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost'; +const scenarioToken = process.env.PLAYWRIGHT_SCENARIO_TOKEN || 'my-token'; +const automationApiKey = process.env.PLAYWRIGHT_AUTOMATION_API_KEY; +const defaultPassword = 'changeme123456'; +const projectTitle = `Simple Author Suite${runId}`; +const scenarioPath = path.resolve(__dirname, './playwright_simple_author.yaml'); +const desktopViewport = { width: 1280, height: 900 }; +const phoneViewport = { width: 500, height: 900 }; + +let sectionSlug = ''; +let seededCourse: AutomationSetupResponse | undefined; + +setRuntimeConfig({ + baseUrl, + scenarioToken, + loginData: { + author: { + type: TYPE_USER.author, + pageTitle: 'OLI Torus', + role: 'Course Author', + welcomeText: 'Welcome to OLI Torus', + welcomeTitle: 'Course Author', + email: `simple-author${runId}@example.com`, + pass: defaultPassword, + header: 'Course Author', + }, + student: { + type: TYPE_USER.student, + pageTitle: 'OLI Torus', + role: 'Student', + welcomeText: 'Welcome to OLI Torus', + welcomeTitle: 'Hi, Test', + email: `simple-student${runId}@example.com`, + name: 'Test', + last_name: 'Learner', + pass: defaultPassword, + }, + }, +}); + +test.beforeAll(async ({ seedScenario }) => { + const result = await seedScenario(scenarioPath, { RUN_ID: runId }); + const outputs = result.outputs as + | { projects?: Record; sections?: Record } + | undefined; + + const projectSlug = outputs?.projects?.[projectTitle] ?? ''; + sectionSlug = outputs?.sections?.[projectTitle] ?? ''; + + expect(projectSlug, 'Scenario did not return the Simple Author project slug').toBeTruthy(); + expect(sectionSlug, 'Scenario did not return the Simple Author section slug').toBeTruthy(); + + seededCourse = { + success: true, + author: { email: `simple-author${runId}@example.com`, password: defaultPassword }, + educator: { email: `simple-instructor${runId}@example.com`, password: defaultPassword }, + learner: { email: `simple-student${runId}@example.com`, password: defaultPassword }, + project: { slug: projectSlug, title: projectTitle }, + section: { slug: sectionSlug }, + }; +}); + +// The PR job runs against an ephemeral database, so teardown only runs when an +// automation API key is available (local runs and persistent deployments). +test.afterAll(async ({ request }, testInfo) => { + testInfo.setTimeout(180_000); + + try { + if (seededCourse && automationApiKey) { + await teardownAutomationCourse(request, seededCourse, { + baseUrl, + apiKey: automationApiKey, + strictTeardown: true, + teardownTimeoutMs: 120_000, + }); + } + } finally { + resetRuntimeConfig(); + } +}); + +type ComponentCase = { + name: string; + screenType: SimpleAuthorScreenType; + configure: (editor: SimpleAuthorPO) => Promise; + verifyPersisted: (editor: SimpleAuthorPO) => Promise; +}; + +const imageUrl = `${baseUrl}/images/oli_torus_logo.png`; +const videoUrl = `${baseUrl}/test/support/video-test-01.mp4`; + +/** + * One row per Phase 1 component. Each row authors the component on its own + * screen, reloads the editor once every write settled, and checks that the + * configuration survived the round trip. + */ +const componentCases: ComponentCase[] = [ + { + name: 'static text', + screenType: 'Instructional Screen', + configure: async (editor) => { + await editor.setTextFlowText('para-1', 'Static text authored by automation'); + }, + verifyPersisted: async (editor) => { + await expect(editor.part('para-1')).toContainText('Static text authored by automation'); + }, + }, + { + name: 'image', + screenType: 'Instructional Screen', + configure: async (editor) => { + const imageId = await editor.addComponent('janus_image'); + await editor.selectPart(imageId); + await editor.setMediaUrl('Select Image', imageUrl); + await editor.fillField('custom_alt', 'Torus logo'); + }, + verifyPersisted: async (editor) => { + const [imageId] = await editor.partIds('janus-image'); + expect(imageId, 'The image part should be persisted').toBeTruthy(); + await editor.selectPart(imageId); + await expect(editor.tabPanel()).toContainText(imageUrl); + await expect(editor.field('custom_alt')).toHaveValue('Torus logo'); + }, + }, + { + name: 'video', + screenType: 'Instructional Screen', + configure: async (editor) => { + const videoId = await editor.addComponent('janus_video'); + await editor.selectPart(videoId); + await editor.setMediaUrl('Select Video File', videoUrl); + }, + verifyPersisted: async (editor) => { + const [videoId] = await editor.partIds('janus-video'); + expect(videoId, 'The video part should be persisted').toBeTruthy(); + await editor.selectPart(videoId); + await expect(editor.tabPanel()).toContainText(videoUrl); + }, + }, + { + name: 'multiple choice', + screenType: 'Multiple Choice', + configure: async (editor) => { + await editor.selectPart('question-1'); + await editor.setMcqCorrectAnswer('Option 2'); + await editor.fillField('custom_correctFeedback', 'Correct, well done'); + await editor.fillField('custom_incorrectFeedback', 'Not quite, try again'); + }, + verifyPersisted: async (editor) => { + await editor.selectPart('question-1'); + await expect(editor.field('custom_correctFeedback')).toHaveValue('Correct, well done'); + await expect(editor.field('custom_incorrectFeedback')).toHaveValue('Not quite, try again'); + await expect(editor.mcqCorrectAnswerSelect()).toHaveValue('1'); + await expect(editor.part('question-1').getByRole('radio')).toHaveCount(3); + }, + }, + { + name: 'multi-select', + screenType: 'Multiple Choice', + configure: async (editor) => { + await editor.selectPart('question-1'); + await editor.setCheckbox('custom_multipleSelection', true); + await expect(editor.part('question-1').getByRole('checkbox')).toHaveCount(3); + await editor.setMcqCorrectOption('Option 3', true); + }, + verifyPersisted: async (editor) => { + await editor.selectPart('question-1'); + await expect(editor.field('custom_multipleSelection')).toBeChecked(); + await expect(editor.part('question-1').getByRole('checkbox')).toHaveCount(3); + await expect(editor.mcqCorrectAnswerCheckbox('Option 1')).toBeChecked(); + await expect(editor.mcqCorrectAnswerCheckbox('Option 2')).not.toBeChecked(); + await expect(editor.mcqCorrectAnswerCheckbox('Option 3')).toBeChecked(); + }, + }, + { + name: 'hub and spoke', + screenType: 'Hub and Spoke', + configure: async (editor) => { + await editor.selectPart('question-1'); + await editor.addHubSpoke('Spoke 4'); + await expect(editor.part('question-1')).toContainText('Spoke 4'); + await editor.selectField('custom_requiredSpoke', '2'); + await editor.fillField('custom_correctFeedback', 'All required spokes visited'); + }, + verifyPersisted: async (editor) => { + await editor.selectPart('question-1'); + await expect(editor.field('custom_requiredSpoke')).toHaveValue('2'); + await expect(editor.field('custom_correctFeedback')).toHaveValue( + 'All required spokes visited', + ); + await expect(editor.part('question-1')).toContainText('Spoke 4'); + }, + }, +]; + +// The component cases share one signed-in page with the editor open, so they +// skip a login and an editor load each. Every case still reloads the editor to +// prove its configuration persisted. +test.describe.serial('Simple Author components @pr', () => { + let context: BrowserContext; + let simpleAuthorTask: SimpleAuthorTask; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180_000); + context = await browser.newContext({ + baseURL: baseUrl, + ignoreHTTPSErrors: true, + viewport: { width: 1920, height: 1080 }, + }); + + const page = await context.newPage(); + const homeTask = new HomeTask(page); + await homeTask.goToSite('/'); + await homeTask.login('author'); + simpleAuthorTask = new SimpleAuthorTask(page); + await simpleAuthorTask.createLesson(projectTitle, 'Simple Author Components'); + await simpleAuthorTask.editor.switchToScreenPanel(); + }); + + test.afterAll(async () => { + await context?.close(); + }); + + for (const componentCase of componentCases) { + test(`${componentCase.name} keeps its configuration after save and refresh`, async () => { + test.setTimeout(120_000); + const screenTitle = `${componentCase.name} screen`; + const editor = simpleAuthorTask.editor; + + await simpleAuthorTask.addScreen(screenTitle, componentCase.screenType); + await componentCase.configure(editor); + + await simpleAuthorTask.reloadAndOpenScreen(screenTitle); + await componentCase.verifyPersisted(editor); + }); + } + + test('copy/paste and undo/redo update the screen and respect question limits', async () => { + test.setTimeout(150_000); + const editor = simpleAuthorTask.editor; + const textFlows = editor.partsOfType('janus-text-flow'); + + await simpleAuthorTask.addScreen('Copy paste screen', 'Instructional Screen'); + await expect(textFlows).toHaveCount(3); + + await editor.selectPart('para-1'); + await editor.copySelectedPart(); + await editor.pasteWithToolbar(); + + await expect(textFlows).toHaveCount(4); + const pastedId = (await editor.partIds('janus-text-flow')).find( + (id) => !['header-1', 'para-1', 'para-2'].includes(id), + ); + if (!pastedId) throw new Error('The pasted text flow should be on the stage'); + expect(pastedId).toMatch(/^janus-text-flow-\d+$/); + const pasted = editor.part(pastedId); + await expect(pasted).toContainText( + (await editor.part('para-1').innerText()).trim().slice(0, 40), + ); + + // The clipboard is single-use, so a second paste adds nothing. + await expect(editor.pasteButton()).toBeHidden(); + await editor.pasteWithKeyboard('para-2'); + await editor.waitForSaves(); + await expect(textFlows).toHaveCount(4); + + await editor.undoButton().click(); + await expect(pasted).toHaveCount(0); + await expect(textFlows).toHaveCount(3); + await editor.redoButton().click(); + await expect(pasted).toHaveCount(1); + + await simpleAuthorTask.reloadAndOpenScreen('Copy paste screen'); + await expect(pasted).toHaveCount(1); + await expect(textFlows).toHaveCount(4); + + // A screen keeps a single question component, even through paste. + await simpleAuthorTask.addScreen('Paste limits screen', 'Multiple Choice'); + await editor.selectPart('question-1'); + await editor.copySelectedPart(); + await expect(editor.pasteButton()).toBeDisabled(); + await editor.pasteWithKeyboard('question-1'); + await expect(editor.pasteBlockedModal()).toContainText( + 'Only one question component per screen is allowed', + ); + await editor.pasteBlockedModal().getByRole('button', { name: 'Close' }).first().click(); + await expect(editor.pasteBlockedModal()).toBeHidden(); + await expect(editor.partsOfType('janus-mcq')).toHaveCount(1); + + // Static components can still be pasted next to the question. + await editor.selectPart('para-1'); + await editor.copySelectedPart(); + await editor.pasteWithToolbar(); + await expect(textFlows).toHaveCount(4); + await expect(editor.partsOfType('janus-mcq')).toHaveCount(1); + }); +}); + +test.describe.serial('Simple Author lesson delivery @pr', () => { + const lessonTitle = 'Simple Author Delivery'; + const quizTitle = 'Quiz Screen'; + const sliderTitle = 'Slider Screen'; + const sliderFeedback = 'Close, 2.5 is the halfway mark'; + + test('author validates, scores, and publishes a multi-screen lesson', async ({ + homeTask, + simpleAuthorTask, + }) => { + test.setTimeout(300_000); + const editor = simpleAuthorTask.editor; + + await homeTask.login('author'); + await simpleAuthorTask.createLesson(projectTitle, lessonTitle); + await editor.switchToScreenPanel(); + + // Responsive layout: a half-width paragraph and image share a row on the + // welcome screen, followed by a full-width paragraph. + await editor.selectScreen('Welcome Screen'); + await editor.setPartWidth('para-1', '50% left'); + await editor.setPartWidth('image-1', '50% right'); + await editor.setPartWidth('para-2', '100%'); + const videoId = await editor.addComponent('janus_video'); + await editor.selectPart(videoId); + await editor.setMediaUrl('Select Video File', videoUrl); + + await simpleAuthorTask.addScreen(quizTitle, 'Multiple Choice'); + await editor.selectPart('question-1'); + await editor.fillField('custom_incorrectFeedback', 'Not quite, try again'); + await editor.openTab('Screen'); + await editor.fillField('checkButton_checkButtonLabel', 'Check Answer'); + await editor.selectField('max_maxAttempt', '4'); + await editor.fillField('max_maxScore', '10'); + + // Decimal values in slider advanced feedback must not be truncated. + await simpleAuthorTask.addScreen(sliderTitle, 'Slider'); + await editor.selectPart('question-1'); + await editor.fillField('custom_maximum', '5'); + await editor.fillField('custom_snapInterval', '0.5'); + await editor.fillField('custom_answer_correctAnswer', '4'); + await editor.addSliderFeedbackRule('Equal to', ['2.5'], sliderFeedback); + + await simpleAuthorTask.reloadAndOpenScreen(sliderTitle); + await editor.selectPart('question-1'); + await expect(editor.field('custom_snapInterval')).toHaveValue('0.5'); + await expect(editor.sliderFeedbackRules()).toHaveCount(1); + await expect(editor.sliderFeedbackRules().locator('input[type="number"]')).toHaveValue('2.5'); + await expect(editor.sliderFeedbackRules().locator('textarea')).toHaveValue(sliderFeedback); + + await editor.selectScreen(quizTitle); + await editor.openTab('Screen'); + await expect(editor.field('checkButton_checkButtonLabel')).toHaveValue('Check Answer'); + await expect(editor.field('max_maxAttempt')).toHaveValue('4'); + await expect(editor.field('max_maxScore')).toHaveValue('10'); + await editor.scoringOverviewButton().click(); + await expect(editor.scoringOverview()).toContainText(quizTitle); + await expect(editor.scoringOverview()).toContainText('Sum of All Scores: 10'); + await editor.closeScoringOverview(); + + // Screen validation and navigation are configured in the flowchart. + await editor.switchToFlowchart(); + await editor.selectFlowchartScreen(quizTitle); + await expect(editor.validationErrors()).toContainText(['No path leads to this screen']); + + await editor.setPaths('Welcome Screen', [['Always', quizTitle]]); + await editor.setPaths(quizTitle, [ + ['Correct', sliderTitle], + ['Any Incorrect', 'End of Lesson'], + ]); + await editor.setPaths(sliderTitle, [ + ['Correct', 'End of Lesson'], + ['Any Incorrect', 'End of Lesson'], + ]); + + for (const screen of ['Welcome Screen', quizTitle, sliderTitle]) { + await editor.selectFlowchartScreen(screen); + await expect(editor.validationErrors(), `${screen} should be valid`).toHaveCount(0); + await expect(editor.flowchartNode(screen)).not.toContainText('This screen is not validated.'); + } + + await simpleAuthorTask.publish(); + }); + + test('student navigates, gets check-button feedback, and sees the responsive layout', async ({ + homeTask, + page, + }) => { + test.setTimeout(240_000); + const deck = new AdaptiveDeckPO(page); + + await homeTask.login('student'); + await openLessonAsStudent(page, lessonTitle); + await deck.waitForDeckReady(); + await page.setViewportSize(desktopViewport); + + // Responsive layout: side by side on desktop, stacked on a phone viewport. + await expect(page.locator('[data-adaptive-responsive-layout="true"]')).toBeAttached(); + const left = deliveryItem(page, 'para-1'); + const right = deliveryItem(page, 'image-1'); + const fullWidth = deliveryItem(page, 'para-2'); + await expect(left).toHaveClass(/half-width/); + await expect(left).toHaveClass(/responsive-align-left/); + await expect(right).toHaveClass(/half-width/); + await expect(right).toHaveClass(/responsive-align-right/); + await expect(fullWidth).toHaveClass(/full-width/); + await expectSideBySide(left, right); + + await page.setViewportSize(phoneViewport); + await expectStacked(left, right); + await page.setViewportSize(desktopViewport); + await expectSideBySide(left, right); + + // The video authored on the welcome screen loads in delivery. + await expectVideoLoaded(page.locator('janus-video video')); + + await deck.footerButton().click(); + await expect(deck.footerButton()).toHaveText('Check Answer', { timeout: 30_000 }); + + // A wrong first answer shows the authored feedback and keeps the student on the screen. + const mcq = page.locator('janus-mcq'); + await mcq.getByText('Option 2', { exact: true }).click(); + await deck.submitCheck(); + await deck.waitForFeedbackOpen(); + await expect(page.locator('.feedbackContainer')).toContainText('Not quite, try again'); + await expect(mcq).toBeVisible(); + + // Answering correctly and checking again follows the "Correct" path to the slider screen. + // With the feedback still open, the footer control re-checks the current + // answer, so the student just changes the answer and checks again. + const correctOption = mcq.getByRole('radio', { name: 'Option 1' }); + await mcq.getByText('Option 1', { exact: true }).click(); + await expect(correctOption).toBeChecked(); + await deck.footerButton().click(); + const slider = page.locator('janus-slider input[type="range"]'); + await advancePastFeedback(deck, slider); + + // The decimal advanced-feedback rule matches the learner's value. + await slider.fill('2.5'); + await expect(slider).toHaveValue('2.5'); + await deck.submitCheck(); + await deck.waitForFeedbackOpen(); + await expect(page.locator('.feedbackContainer')).toContainText(sliderFeedback); + }); +}); + +async function openLessonAsStudent(page: Page, lessonTitle: string) { + const studentCourse = new StudentCoursePO(page); + + await page.goto(`/sections/${sectionSlug}`); + await studentCourse.goToCourseIfPrompted(); + await page.goto(`/sections/${sectionSlug}/learn`); + await studentCourse.openPage(lessonTitle); +} + +function deliveryItem(page: Page, partId: string) { + return page.locator(`.responsive-item[data-part-id="${partId}"]`); +} + +/** Clicks through the post-check feedback until the target screen renders. */ +async function advancePastFeedback(deck: AdaptiveDeckPO, target: Locator) { + for (let attempt = 0; attempt < 3; attempt += 1) { + if (await target.isVisible().catch(() => false)) return; + if (await deck.feedbackVisible()) await deck.acknowledgeFeedback(); + await target.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => undefined); + } + await expect(target).toBeVisible(); +} + +async function expectSideBySide(left: Locator, right: Locator) { + await expect + .poll(async () => { + const [a, b] = await Promise.all([left.boundingBox(), right.boundingBox()]); + return !!a && !!b && Math.abs(a.y - b.y) < 2 && a.x + a.width <= b.x + 1; + }) + .toBe(true); +} + +/** Stacked items share the column and do not overlap, in whichever order they render. */ +async function expectStacked(first: Locator, second: Locator) { + await expect + .poll(async () => { + const [a, b] = await Promise.all([first.boundingBox(), second.boundingBox()]); + if (!a || !b) return false; + const sameColumn = Math.abs(a.x - b.x) < 2 && Math.abs(a.width - b.width) < 2; + const noOverlap = b.y >= a.y + a.height - 1 || a.y >= b.y + b.height - 1; + return sameColumn && noOverlap; + }) + .toBe(true); +} + +/** The media element reached HAVE_METADATA (readyState >= 1) without a media error. */ +async function expectVideoLoaded(video: Locator) { + await expect(video).toBeAttached(); + await expect + .poll( + () => + video.evaluate( + (element: HTMLVideoElement) => element.readyState >= 1 && element.error === null, + ), + { message: 'The authored video should load in delivery' }, + ) + .toBe(true); +} diff --git a/assets/automation/tests/torus/course_authoring/simple_author.md b/assets/automation/tests/torus/course_authoring/simple_author.md new file mode 100644 index 00000000000..0cfe697f000 --- /dev/null +++ b/assets/automation/tests/torus/course_authoring/simple_author.md @@ -0,0 +1,75 @@ +# Simple Author automated tests (MER-5988) + +Phase 1 Playwright coverage for the adaptive Simple Author (flowchart-mode) editor and the +delivery of a lesson built with it. + +## Files + +| File | Purpose | +| -------------------------------------------------- | ------------------------------------------------------------------------ | +| `simple-author.spec.ts` | The suite. | +| `playwright_simple_author.yaml` | Scenario seed: disposable author/educator/learner, project, and section. | +| `src/systems/torus/pom/page/SimpleAuthorPO.ts` | Editor page object: screens, parts, property panel, paste, flowchart. | +| `src/systems/torus/tasks/SimpleAuthorTask.ts` | Workflows: create/open lesson, add screen, reload, publish. | +| `src/systems/torus/pom/delivery/AdaptiveDeckPO.ts` | Reused for the delivery deck (check button, feedback). | + +## Coverage + +| Test | Asserts | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ` keeps its configuration ...` | One parameterized row per component (static text, image, video, MCQ, multi-select, hub and spoke): author it on its own screen, reload, check the configuration. | +| `copy/paste and undo/redo ...` | Paste adds a part with a new id; undo/redo; the result survives a reload. Regressions MER-3420, MER-4109, MER-4572. | +| `author validates, scores, and publishes ...` | Screen settings (check label, max attempts, max score) and slider advanced feedback (MER-3919) persist; Scoring Overview; flowchart validation errors clear once paths are wired. | +| `student navigates, gets check-button ...` | Responsive layout side by side at 1280px and stacked at 500px; the authored video loads; custom check label; incorrect feedback keeps the student on the screen; correct path navigation; decimal slider feedback (MER-3919). | + +Deferred to later phases: popup, iframe, audio, flashcards, and less common component +permutations. Broad exploratory and subjective visual review stays manual. + +## Setup and teardown + +- `beforeAll` seeds `playwright_simple_author.yaml` with a per-run `RUN_ID`. Lessons, screens, + and components are created through the UI, because that is what the suite covers. +- The component cases run serially on one signed-in page with the editor already open, created + in `beforeAll`. Each case still reloads the editor to verify persistence, and Playwright's + trace for each test includes that shared page. +- Media is set through the picker's "External URL" tab, so no S3 upload is needed. The image is + Torus's own `/images/oli_torus_logo.png`. The video is the shared fixture + `tests/resources/media_files/video-test-01.mp4`, served by `GET /test/support/video-test-01.mp4` + (`PlaywrightSupportAssetController`, only routed when Playwright scenarios are enabled). +- `afterAll` calls the guarded automation teardown with `strictTeardown` when + `PLAYWRIGHT_AUTOMATION_API_KEY` is set. The seeded names ("Test Author/Educator/Learner", + "Automation test section") meet its contract. Without a key, for example in the PR job, which + runs against an ephemeral database, teardown is skipped. + +## Running + +```bash +cd assets/automation +PLAYWRIGHT_BASE_URL=http://localhost:4000 PLAYWRIGHT_SCENARIO_TOKEN=my-token npm run test-simple-author +``` + +## CI placement + +Every describe is tagged `@pr`, so the suite runs in the PR Playwright Suite +(`.github/workflows/pr-playwright.yml`, `npx playwright test --grep @pr`). It only depends on +data it seeds and needs no third-party credentials. It is not tagged `@nightly`, because the +nightly job targets a persistent deployment where `/test/scenario-yaml` is not available. + +## Editor behaviors the page object handles + +- Simple Author has no "All changes saved" indicator. `waitForSaves()` tracks the + `/api/v1/storage/...` and `/api/v1/project/.../resource|activity` writes and waits for an + 800ms quiet window, measured from the call, longer than the editor's 500ms save debounce. Every editing + action waits for its write. Without this, quick successive edits, such as editing right + after a screen is created, can persist an older state over a newer one. +- A reopened lesson can load in read-only mode. `BasicPracticePagePO.disableAdaptiveReadOnly` + switches it off. Once the editor header renders, the onboarding wizard is not showing, so the + editor load does not wait for it. +- Creating the first screen after a load can re-render the editor and drop the "Add new screen" + dialog without creating anything. `addScreen` retries only when no screen-creation request + went out, so a creation in flight is never repeated. +- Right after a screen is created, the toolbar can drop the first component click. + `addComponent` retries only while no new part appeared, so a slow add is never doubled. +- Adding a screen from the Screen Panel adds "Unknown Rule" paths. The editor also re-sorts + paths and never leaves a screen without one. `setPaths` therefore converges each screen to + the exact rule set and asserts it. diff --git a/assets/automation/tests/torus/student_delivery/image-coding-delivery.scenario.yaml b/assets/automation/tests/torus/student_delivery/image-coding-delivery.scenario.yaml index 09e4c9068bc..d7da5284013 100644 --- a/assets/automation/tests/torus/student_delivery/image-coding-delivery.scenario.yaml +++ b/assets/automation/tests/torus/student_delivery/image-coding-delivery.scenario.yaml @@ -85,10 +85,10 @@ children: - text: 'Load the image resource and submit the expected rendered result.' isExample: false - starterCode: 'image = new SimpleImage("image_coding_sample.png"); print(image)' - solutionCode: 'image = new SimpleImage("image_coding_sample.png"); print(image)' + starterCode: 'image = new SimpleImage("image-test-01.png"); print(image)' + solutionCode: 'image = new SimpleImage("image-test-01.png"); print(image)' resourceURLs: - - '/test/support/image_coding_sample.png' + - '/test/support/image-test-01.png' tolerance: 0.1 regex: '' feedback: @@ -147,10 +147,10 @@ children: - text: 'Load the table resource and submit the expected value.' isExample: false - starterCode: 'var table = new SimpleTable("image_coding_table.csv"); print(table.getRow(0).getField("name"))' - solutionCode: 'var table = new SimpleTable("image_coding_table.csv"); print(table.getRow(1).getField("value"))' + starterCode: 'var table = new SimpleTable("table-test-01.csv"); print(table.getRow(0).getField("name"))' + solutionCode: 'var table = new SimpleTable("table-test-01.csv"); print(table.getRow(1).getField("value"))' resourceURLs: - - '/test/support/image_coding_table.csv' + - '/test/support/table-test-01.csv' tolerance: 1.0 regex: '7' feedback: diff --git a/assets/automation/tests/torus/student_delivery/image-coding-delivery.spec.ts b/assets/automation/tests/torus/student_delivery/image-coding-delivery.spec.ts index da315c057b1..dd9d4d86ba4 100644 --- a/assets/automation/tests/torus/student_delivery/image-coding-delivery.spec.ts +++ b/assets/automation/tests/torus/student_delivery/image-coding-delivery.spec.ts @@ -25,15 +25,15 @@ const activityStems = { }; const imageCodingImagePrograms = { - exact: ['image = new SimpleImage("image_coding_sample.png");', 'print(image);'].join('\n'), + exact: ['image = new SimpleImage("image-test-01.png");', 'print(image);'].join('\n'), slightDifference: [ - 'image = new SimpleImage("image_coding_sample.png");', + 'image = new SimpleImage("image-test-01.png");', 'pixel = image.getPixel(0, 0);', 'pixel.setRed(pixel.getRed() + 1);', 'print(image);', ].join('\n'), largeDifference: [ - 'image = new SimpleImage("image_coding_sample.png");', + 'image = new SimpleImage("image-test-01.png");', 'var width = image.getWidth();', 'var height = image.getHeight();', 'var targetWidth = Math.floor(width * 0.3);', @@ -253,7 +253,7 @@ test.describe('image coding delivery', () => { // Table-processing correct path: read the expected csv field through SimpleTable. await setImageCodingSource( activity, - 'var table = new SimpleTable("image_coding_table.csv"); print(table.getRow(1).getField("value"))', + 'var table = new SimpleTable("table-test-01.csv"); print(table.getRow(1).getField("value"))', ); await runImageCodingUntilTextReady(activity, output, '7'); await expect(output).toContainText('7'); @@ -267,7 +267,7 @@ test.describe('image coding delivery', () => { await setImageCodingSource( activity, - 'var table = new SimpleTable("image_coding_table.csv"); print(table.getRow(0).getField("name"))', + 'var table = new SimpleTable("table-test-01.csv"); print(table.getRow(0).getField("name"))', ); await runImageCodingUntilTextReady(activity, output, 'alpha'); await expect(output).toContainText('alpha'); diff --git a/lib/oli_web/controllers/playwright_support_asset_controller.ex b/lib/oli_web/controllers/playwright_support_asset_controller.ex index 9c99084ce92..193ebd0d3b6 100644 --- a/lib/oli_web/controllers/playwright_support_asset_controller.ex +++ b/lib/oli_web/controllers/playwright_support_asset_controller.ex @@ -7,9 +7,10 @@ defmodule OliWeb.PlaywrightSupportAssetController do This controller handles: - the embedded runtime stub needed by delivery automation when legacy superactivity assets are unavailable locally - - small fixture files that live alongside the Playwright specs so activity - tests can exercise resource-loading behavior without depending on the media - library or object storage + - small fixture files from the shared Playwright media fixtures folder + (`assets/automation/tests/resources/media_files`) so tests can exercise + resource-loading behavior without depending on the media library or object + storage - private test assets (course archives, answer keys) proxied from the Playwright assets bucket; these require the scenario token because their contents must not be publicly reachable @@ -19,8 +20,9 @@ defmodule OliWeb.PlaywrightSupportAssetController do alias OliWeb.PlaywrightAuth @allowed_files %{ - "image_coding_sample.png" => "image/png", - "image_coding_table.csv" => "text/csv" + "image-test-01.png" => "image/png", + "table-test-01.csv" => "text/csv", + "video-test-01.mp4" => "video/mp4" } # private_asset serves S3-controlled content: never trust its content-type @@ -73,7 +75,7 @@ defmodule OliWeb.PlaywrightSupportAssetController do defp asset_path(filename) do Path.expand( - "../../../assets/automation/tests/torus/student_delivery/support/#{filename}", + "../../../assets/automation/tests/resources/media_files/#{filename}", __DIR__ ) end diff --git a/test/oli_web/controllers/playwright_support_asset_controller_test.exs b/test/oli_web/controllers/playwright_support_asset_controller_test.exs index bd5b4077e06..d5652bde68c 100644 --- a/test/oli_web/controllers/playwright_support_asset_controller_test.exs +++ b/test/oli_web/controllers/playwright_support_asset_controller_test.exs @@ -16,12 +16,19 @@ defmodule OliWeb.PlaywrightSupportAssetControllerTest do end test "serves an allowed support asset", %{conn: conn} do - conn = get(conn, "/test/support/image_coding_table.csv") + conn = get(conn, "/test/support/table-test-01.csv") assert response_content_type(conn, :csv) =~ "text/csv" assert response(conn, 200) =~ "name,value" end + test "serves the shared automation video fixture", %{conn: conn} do + conn = get(conn, "/test/support/video-test-01.mp4") + + assert response(conn, 200) + assert conn |> get_resp_header("content-type") |> List.first() =~ "video/mp4" + end + test "rejects unknown support assets", %{conn: conn} do conn = get(conn, "/test/support/does_not_exist.txt")