From b0765adcf459b0c2ea458484fa25bf5dcc4b8313 Mon Sep 17 00:00:00 2001 From: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:24:42 -0400 Subject: [PATCH 1/2] feat: add the frontend-base route tree with stub pages, roles and provides One root route at `learning` with the Learning role, rendering Main; course layout routes at `course/:courseId` and `preview/course/:courseId` carrying the `learningCourse` role, which `provides` lists for the shell's course bar and masquerade bar so both mount only where a course id exists; a child route for every URL Learning serves today, each rendering a stub that names its page as not yet ported, in Paragon's Container size xl so it lines up with the shell's header and course bar; a role on the four destination tabs. Nothing fetches and no provider mounts. Route tests render the exported array through createMemoryRouter at every path. jsdom has no Fetch API and the data router builds a Request for a route with `lazy`, so a jest environment forwards Node's fetch classes into the sandbox, as react-router's own suite does from undici. The shared render helper lives in src/tests, which the build and coverage both leave out. Part of #2188. Co-Authored-By: Claude Fable 5.1 --- frontend-base/jest.config.js | 2 + .../jsdom-environment-with-node-fetch.mjs | 16 ++ frontend-base/src/StubPage.tsx | 21 +++ frontend-base/src/app.test.ts | 102 +++++++++- frontend-base/src/app.ts | 2 + frontend-base/src/constants.ts | 6 + frontend-base/src/provides.ts | 10 + frontend-base/src/routes.test.tsx | 123 ++++++++++++ frontend-base/src/routes.tsx | 175 +++++++++++++++++- frontend-base/src/tests/render.tsx | 9 + frontend-base/tsconfig.build.json | 1 + frontend-base/tsconfig.json | 1 + 12 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 frontend-base/jsdom-environment-with-node-fetch.mjs create mode 100644 frontend-base/src/StubPage.tsx create mode 100644 frontend-base/src/provides.ts create mode 100644 frontend-base/src/routes.test.tsx create mode 100644 frontend-base/src/tests/render.tsx diff --git a/frontend-base/jest.config.js b/frontend-base/jest.config.js index c8c8e83611..9d7a74f39a 100644 --- a/frontend-base/jest.config.js +++ b/frontend-base/jest.config.js @@ -1,6 +1,7 @@ const { createConfig } = require('@openedx/frontend-base/tools'); module.exports = createConfig('test', { + testEnvironment: '/jsdom-environment-with-node-fetch.mjs', setupFilesAfterEnv: [ '/src/setupTest.js', ], @@ -13,6 +14,7 @@ module.exports = createConfig('test', { 'src/setupTest.js', 'src/i18n', 'src/__mocks__', + 'src/tests', ], moduleNameMapper: { '^@src/(.*)$': '/src/$1', diff --git a/frontend-base/jsdom-environment-with-node-fetch.mjs b/frontend-base/jsdom-environment-with-node-fetch.mjs new file mode 100644 index 0000000000..fdc6e3ef37 --- /dev/null +++ b/frontend-base/jsdom-environment-with-node-fetch.mjs @@ -0,0 +1,16 @@ +// This file can be removed if https://github.com/jsdom/jsdom/issues/1724 is resolved. +import jsdom from 'jest-environment-jsdom'; + +const JSDOMEnvironment = jsdom.default; + +export default class JSDOMEnvironmentWithNodeFetch extends JSDOMEnvironment { + constructor(...args) { + super(...args); + this.global.AbortController = AbortController; + this.global.AbortSignal = AbortSignal; + this.global.fetch = fetch; + this.global.Headers = Headers; + this.global.Request = Request; + this.global.Response = Response; + } +} diff --git a/frontend-base/src/StubPage.tsx b/frontend-base/src/StubPage.tsx new file mode 100644 index 0000000000..356ffbc21d --- /dev/null +++ b/frontend-base/src/StubPage.tsx @@ -0,0 +1,21 @@ +import { Container } from '@openedx/paragon'; +import { useParams } from 'react-router-dom'; + +interface StubPageProps { + page: string; +} + +// Stands in for a page until its port replaces it; deleted with the last port. +const StubPage = ({ page }: StubPageProps) => { + const params = useParams(); + return ( + +

{page}: not yet ported

+
    + {Object.entries(params).map(([name, value]) =>
  • {`${name}=${value}`}
  • )} +
+
+ ); +}; + +export default StubPage; diff --git a/frontend-base/src/app.test.ts b/frontend-base/src/app.test.ts index 076ef911ea..96434ecc62 100644 --- a/frontend-base/src/app.test.ts +++ b/frontend-base/src/app.test.ts @@ -1,9 +1,107 @@ +import { + getUrlByRouteRole, + mergeSiteConfig, + providesCourseBarMasqueradeRolesId, + providesCourseBarRolesId, +} from '@openedx/frontend-base'; + import { learningApp } from './index'; +import { + courseDatesRole, + courseOutlineRole, + courseProgressRole, + coursewareRole, + learningCourseRole, + learningRole, +} from './constants'; + +const [main] = learningApp.routes ?? []; +const [course, preview, ...nonCourse] = main.children ?? []; describe('learningApp', () => { - it('declares the Learning app id with no routes or slot operations yet', () => { + it('declares the Learning app id with no slot operations yet', () => { expect(learningApp.appId).toBe('org.openedx.frontend.app.learning'); - expect(learningApp.routes).toEqual([]); expect(learningApp.slots).toEqual([]); }); + + it('provides the course role for the course bar and the masquerade bar', () => { + expect(learningApp.provides).toEqual({ + [providesCourseBarRolesId]: [learningCourseRole], + [providesCourseBarMasqueradeRolesId]: [learningCourseRole], + }); + }); + + it('declares the learning role on the root route', () => { + expect(main.path).toBe('learning'); + expect(main.handle?.roles).toEqual([learningRole]); + }); + + it('declares the course role on the course and preview layout routes', () => { + expect(course.path).toBe('course/:courseId'); + expect(course.handle?.roles).toEqual([learningCourseRole]); + expect(preview.path).toBe('preview/course/:courseId'); + expect(preview.handle?.roles).toEqual([learningCourseRole]); + }); + + it('declares a destination role on four course pages and none on the rest', () => { + const declaredRolesByCoursePath = Object.fromEntries( + (course.children ?? []).map((route) => [route.index ? '(index)' : route.path, route.handle?.roles]), + ); + expect(declaredRolesByCoursePath).toEqual({ + home: [courseOutlineRole], + dates: [courseDatesRole], + progress: [courseProgressRole], + 'progress/:targetUserId': [courseProgressRole], + 'discussion/:path/*': undefined, + live: undefined, + '(index)': [coursewareRole], + ':sequenceId': [coursewareRole], + ':sequenceId/:unitId': [coursewareRole], + 'course-end': undefined, + 'access-denied': undefined, + }); + expect((preview.children ?? []).map((route) => [route.path, route.handle?.roles])).toEqual([ + [':sequenceId', [coursewareRole]], + [':sequenceId/:unitId', [coursewareRole]], + ]); + expect(nonCourse.map((route) => [route.path, route.handle?.roles])).toEqual([ + ['goal-unsubscribe/:token', undefined], + ['redirect/*', undefined], + ['preferences-unsubscribe/:userToken/:updatePatch?', undefined], + ]); + }); +}); + +describe('role resolution', () => { + beforeAll(() => { + mergeSiteConfig({ apps: [learningApp] }); + }); + + it('learning resolves to the root, not to any page', () => { + expect(getUrlByRouteRole(learningRole)).toBe('/learning'); + }); + + it('learningCourse resolves to the course, which prefix-matches every tab', () => { + expect(getUrlByRouteRole(learningCourseRole)).toBe('/learning/course/:courseId'); + }); + + it('courseOutline resolves to the outline tab', () => { + expect(getUrlByRouteRole(courseOutlineRole)).toBe('/learning/course/:courseId/home'); + }); + + it('courseDates resolves to the dates tab', () => { + expect(getUrlByRouteRole(courseDatesRole)).toBe('/learning/course/:courseId/dates'); + }); + + it("courseProgress resolves to the learner's own progress, not another user's", () => { + const url = getUrlByRouteRole(courseProgressRole); + expect(url).toBe('/learning/course/:courseId/progress'); + expect(url).not.toMatch(/:targetUserId/); + }); + + it('courseware resolves to the bare course path (an index route, hence the slash), not a sequence, unit or preview path', () => { + const url = getUrlByRouteRole(coursewareRole); + expect(url).toBe('/learning/course/:courseId/'); + expect(url).not.toMatch(/:sequenceId|:unitId|preview/); + }); }); diff --git a/frontend-base/src/app.ts b/frontend-base/src/app.ts index fec03c6e71..f6d9e98c7b 100644 --- a/frontend-base/src/app.ts +++ b/frontend-base/src/app.ts @@ -1,11 +1,13 @@ import { App } from '@openedx/frontend-base'; import { appId } from '@src/constants'; +import provides from '@src/provides'; import routes from '@src/routes'; import slots from '@src/slots'; const app: App = { appId, routes, + provides, slots, }; diff --git a/frontend-base/src/constants.ts b/frontend-base/src/constants.ts index ba8030562e..f599ffaf43 100644 --- a/frontend-base/src/constants.ts +++ b/frontend-base/src/constants.ts @@ -1,2 +1,8 @@ export const appId = 'org.openedx.frontend.app.learning'; + export const learningRole = 'org.openedx.frontend.role.learning'; +export const learningCourseRole = 'org.openedx.frontend.role.learningCourse'; +export const courseOutlineRole = 'org.openedx.frontend.role.courseOutline'; +export const courseDatesRole = 'org.openedx.frontend.role.courseDates'; +export const courseProgressRole = 'org.openedx.frontend.role.courseProgress'; +export const coursewareRole = 'org.openedx.frontend.role.courseware'; diff --git a/frontend-base/src/provides.ts b/frontend-base/src/provides.ts new file mode 100644 index 0000000000..fbf93e2e03 --- /dev/null +++ b/frontend-base/src/provides.ts @@ -0,0 +1,10 @@ +import { providesCourseBarMasqueradeRolesId, providesCourseBarRolesId } from '@openedx/frontend-base'; + +import { learningCourseRole } from './constants'; + +const provides = { + [providesCourseBarRolesId]: [learningCourseRole], + [providesCourseBarMasqueradeRolesId]: [learningCourseRole], +}; + +export default provides; diff --git a/frontend-base/src/routes.test.tsx b/frontend-base/src/routes.test.tsx new file mode 100644 index 0000000000..f270f3e990 --- /dev/null +++ b/frontend-base/src/routes.test.tsx @@ -0,0 +1,123 @@ +import { screen } from '@testing-library/react'; + +import routes from '@src/routes'; +import { renderRoutes } from '@src/tests/render'; + +const courseId = 'course-v1:OpenedX+DemoX+DemoCourse'; + +const renderAt = (path: string) => renderRoutes(routes, `/learning${path}`); +const expectStub = async (page: string, params: Record) => { + expect(await screen.findByText(`${page}: not yet ported`)).toBeInTheDocument(); + Object.entries(params).forEach(([name, value]) => { + expect(screen.getByText(`${name}=${value}`)).toBeInTheDocument(); + }); +}; + +describe('routes', () => { + describe('outline', () => { + it('renders at course/:courseId/home', async () => { + renderAt(`/course/${courseId}/home`); + await expectStub('outline', { courseId }); + }); + }); + + describe('dates', () => { + it('renders at course/:courseId/dates', async () => { + renderAt(`/course/${courseId}/dates`); + await expectStub('dates', { courseId }); + }); + }); + + describe('progress', () => { + it('renders at course/:courseId/progress', async () => { + renderAt(`/course/${courseId}/progress`); + await expectStub('progress', { courseId }); + }); + + it('renders at course/:courseId/progress/:targetUserId with the target user', async () => { + renderAt(`/course/${courseId}/progress/42`); + await expectStub('progress', { courseId, targetUserId: '42' }); + }); + }); + + describe('discussion', () => { + it('renders at course/:courseId/discussion/:path/* with the path and the rest', async () => { + renderAt(`/course/${courseId}/discussion/topics/abc`); + await expectStub('discussion', { courseId, path: 'topics', '*': 'abc' }); + }); + }); + + describe('live', () => { + it('renders at course/:courseId/live', async () => { + renderAt(`/course/${courseId}/live`); + await expectStub('live', { courseId }); + }); + }); + + describe('courseware', () => { + it('renders at course/:courseId', async () => { + renderAt(`/course/${courseId}`); + await expectStub('courseware', { courseId }); + }); + + it('renders at course/:courseId/:sequenceId with the sequence', async () => { + renderAt(`/course/${courseId}/seq`); + await expectStub('courseware', { courseId, sequenceId: 'seq' }); + }); + + it('renders at course/:courseId/:sequenceId/:unitId with both ids', async () => { + renderAt(`/course/${courseId}/seq/unit`); + await expectStub('courseware', { courseId, sequenceId: 'seq', unitId: 'unit' }); + }); + + it('renders at preview/course/:courseId/:sequenceId with the sequence', async () => { + renderAt(`/preview/course/${courseId}/seq`); + await expectStub('courseware', { courseId, sequenceId: 'seq' }); + }); + + it('renders at preview/course/:courseId/:sequenceId/:unitId with both ids', async () => { + renderAt(`/preview/course/${courseId}/seq/unit`); + await expectStub('courseware', { courseId, sequenceId: 'seq', unitId: 'unit' }); + }); + }); + + describe('courseExit', () => { + it('renders at course/:courseId/course-end', async () => { + renderAt(`/course/${courseId}/course-end`); + await expectStub('courseExit', { courseId }); + }); + }); + + describe('accessDenied', () => { + it('renders at course/:courseId/access-denied', async () => { + renderAt(`/course/${courseId}/access-denied`); + await expectStub('accessDenied', { courseId }); + }); + }); + + describe('goalUnsubscribe', () => { + it('renders at goal-unsubscribe/:token with the token', async () => { + renderAt('/goal-unsubscribe/tok'); + await expectStub('goalUnsubscribe', { token: 'tok' }); + }); + }); + + describe('redirect', () => { + it('renders at redirect/* with the rest', async () => { + renderAt('/redirect/dashboard'); + await expectStub('redirect', { '*': 'dashboard' }); + }); + }); + + describe('preferencesUnsubscribe', () => { + it('renders at preferences-unsubscribe/:userToken with the token', async () => { + renderAt('/preferences-unsubscribe/tok'); + await expectStub('preferencesUnsubscribe', { userToken: 'tok' }); + }); + + it('renders at preferences-unsubscribe/:userToken/:updatePatch with the patch', async () => { + renderAt('/preferences-unsubscribe/tok/patch'); + await expectStub('preferencesUnsubscribe', { userToken: 'tok', updatePatch: 'patch' }); + }); + }); +}); diff --git a/frontend-base/src/routes.tsx b/frontend-base/src/routes.tsx index 422793ecbf..02af191726 100644 --- a/frontend-base/src/routes.tsx +++ b/frontend-base/src/routes.tsx @@ -1,5 +1,178 @@ import { RoleRouteObject } from '@openedx/frontend-base'; +import { Outlet } from 'react-router-dom'; -const routes: RoleRouteObject[] = []; +import { + courseDatesRole, + courseOutlineRole, + courseProgressRole, + coursewareRole, + learningCourseRole, + learningRole, +} from './constants'; +import StubPage from './StubPage'; + +const outlineRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.outline', + path: 'home', + handle: { roles: [courseOutlineRole] }, + element: , + }, +]; + +const datesRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.dates', + path: 'dates', + handle: { roles: [courseDatesRole] }, + element: , + }, +]; + +const progressRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.progress', + path: 'progress', + handle: { roles: [courseProgressRole] }, + element: , + }, + { + path: 'progress/:targetUserId', + handle: { roles: [courseProgressRole] }, + element: , + }, +]; + +const discussionRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.discussion', + path: 'discussion/:path/*', + element: , + }, +]; + +const liveRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.live', + path: 'live', + element: , + }, +]; + +const coursewareRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.courseware', + index: true, + handle: { roles: [coursewareRole] }, + element: , + }, + { + path: ':sequenceId', + handle: { roles: [coursewareRole] }, + element: , + }, + { + path: ':sequenceId/:unitId', + handle: { roles: [coursewareRole] }, + element: , + }, +]; + +const previewCoursewareRoutes: RoleRouteObject[] = [ + { + path: ':sequenceId', + handle: { roles: [coursewareRole] }, + element: , + }, + { + path: ':sequenceId/:unitId', + handle: { roles: [coursewareRole] }, + element: , + }, +]; + +const courseExitRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.courseExit', + path: 'course-end', + element: , + }, +]; + +const accessDeniedRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.accessDenied', + path: 'access-denied', + element: , + }, +]; + +const courseRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.course', + path: 'course/:courseId', + handle: { roles: [learningCourseRole] }, + element: , + children: [ + ...outlineRoutes, + ...datesRoutes, + ...progressRoutes, + ...discussionRoutes, + ...liveRoutes, + ...coursewareRoutes, + ...courseExitRoutes, + ...accessDeniedRoutes, + ], + }, + { + id: 'org.openedx.frontend.route.learning.preview', + path: 'preview/course/:courseId', + handle: { roles: [learningCourseRole] }, + element: , + children: previewCoursewareRoutes, + }, +]; + +const goalUnsubscribeRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.goalUnsubscribe', + path: 'goal-unsubscribe/:token', + element: , + }, +]; + +const redirectRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.redirect', + path: 'redirect/*', + element: , + }, +]; + +const preferencesUnsubscribeRoutes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.preferencesUnsubscribe', + path: 'preferences-unsubscribe/:userToken/:updatePatch?', + element: , + }, +]; + +const routes: RoleRouteObject[] = [ + { + id: 'org.openedx.frontend.route.learning.main', + path: 'learning', + handle: { roles: [learningRole] }, + async lazy() { + const module = await import(/* webpackChunkName: "learning-main" */ './Main'); + return { Component: module.default }; + }, + children: [ + ...courseRoutes, + ...goalUnsubscribeRoutes, + ...redirectRoutes, + ...preferencesUnsubscribeRoutes, + ], + }, +]; export default routes; diff --git a/frontend-base/src/tests/render.tsx b/frontend-base/src/tests/render.tsx new file mode 100644 index 0000000000..3b410e240a --- /dev/null +++ b/frontend-base/src/tests/render.tsx @@ -0,0 +1,9 @@ +import { IntlProvider, RoleRouteObject } from '@openedx/frontend-base'; +import { render } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + +export const renderRoutes = (routes: RoleRouteObject[], path: string) => render( + + + , +); diff --git a/frontend-base/tsconfig.build.json b/frontend-base/tsconfig.build.json index a48f6e31e5..e87dac6b92 100644 --- a/frontend-base/tsconfig.build.json +++ b/frontend-base/tsconfig.build.json @@ -14,6 +14,7 @@ "src/**/*.spec.ts", "src/**/*.spec.tsx", "src/__mocks__/**/*", + "src/**/tests/**/*", "src/setupTest.js" ] } diff --git a/frontend-base/tsconfig.json b/frontend-base/tsconfig.json index 0ee4ab357e..255fbc7780 100644 --- a/frontend-base/tsconfig.json +++ b/frontend-base/tsconfig.json @@ -13,6 +13,7 @@ "babel.config.js", "eslint.config.js", "jest.config.js", + "jsdom-environment-with-node-fetch.mjs", "site.config.*.tsx", ], } From 71ca2fc200d13d08eba6e3ef9ccbcb6839c80802 Mon Sep 17 00:00:00 2001 From: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:12:43 -0400 Subject: [PATCH 2/2] test: assert the page rules on every route Each path the route test covers is rendered and, once its stub page is up, runPageRules(document) reports no diagnostics: one
per route, from the page. A page's port moves its paths out of this list and asserts the rules in its own route test. Part of #2188. Co-Authored-By: Claude Opus 5.5 --- frontend-base/src/routes.test.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frontend-base/src/routes.test.tsx b/frontend-base/src/routes.test.tsx index f270f3e990..ebe1a77e52 100644 --- a/frontend-base/src/routes.test.tsx +++ b/frontend-base/src/routes.test.tsx @@ -1,3 +1,4 @@ +import { runPageRules } from '@openedx/frontend-base'; import { screen } from '@testing-library/react'; import routes from '@src/routes'; @@ -120,4 +121,30 @@ describe('routes', () => { await expectStub('preferencesUnsubscribe', { userToken: 'tok', updatePatch: 'patch' }); }); }); + + describe('page rules', () => { + it.each([ + `/course/${courseId}/home`, + `/course/${courseId}/dates`, + `/course/${courseId}/progress`, + `/course/${courseId}/progress/42`, + `/course/${courseId}/discussion/topics/abc`, + `/course/${courseId}/live`, + `/course/${courseId}`, + `/course/${courseId}/seq`, + `/course/${courseId}/seq/unit`, + `/preview/course/${courseId}/seq`, + `/preview/course/${courseId}/seq/unit`, + `/course/${courseId}/course-end`, + `/course/${courseId}/access-denied`, + '/goal-unsubscribe/tok', + '/redirect/dashboard', + '/preferences-unsubscribe/tok', + '/preferences-unsubscribe/tok/patch', + ])('reports no page-rule diagnostics at %s', async (path) => { + renderAt(path); + await screen.findByText(/: not yet ported$/); + expect(runPageRules(document)).toEqual([]); + }); + }); });