@lernapps/app-templates 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
- // The end-to-end tests of every explainer (@lernapps/app-templates/explainer/e2e), run by `lernapps check --pre-push`
2
- // on the built app with the app's own content. An app's e2e/explainer.e2e.ts is
1
+ // The end-to-end tests of the explainer (@lernapps/app-templates/explainer/e2e), run by `lernapps check --pre-push` on
2
+ // the built app with its own content, in two parts. explainerTests() tests the app's content, and every app runs it:
3
+ // its e2e/explainer.e2e.ts, part of the scaffold, is
3
4
  //
4
5
  // import { explainerTests } from "@lernapps/app-templates/explainer/e2e";
5
6
  // explainerTests();
6
7
  //
8
+ // Without JavaScript every page as its content says, in every language; previous and next (at desktop width); the
9
+ // app's own components and the checks of its exercise components. explainerRuntimeTests() tests the runtime, the same
10
+ // in every app: the exercises of each type, the videos, the tutor ways, the start page, the self-assessments and what
11
+ // is stored. Only this repo runs it, on the template and its fixtures (explainer/e2e/runtime.e2e.ts, not in the
12
+ // scaffold). What follows is what the two parts check together.
7
13
  // What they expect comes from the content in src/ (outline.ts), not from the rendered pages. Without JavaScript, for
8
14
  // every page in every language: the heading levels follow the nesting (page h1, section h2, subsection h3, a box one
9
15
  // below its place), every section heading is visible, the number of <math> elements is the number of formulas in the
@@ -827,148 +833,183 @@ async function expectResultLine(
827
833
  expect(await response.text(), "the tutor page has the exercise").toContain(id);
828
834
  }
829
835
 
830
- /** The explainer's tests, for the content in `root`/src. */
836
+ /**
837
+ * The tests of an explainer's content, for the content in `root`/src: what every app runs (e2e/explainer.e2e.ts).
838
+ * Without JavaScript every page as its content says, previous and next, the app's own components and the checks of
839
+ * its exercise components. What the runtime does the same in every app is explainerRuntimeTests.
840
+ */
831
841
  export function explainerTests(root = "."): void {
842
+ explainerSuite(root, "content");
843
+ }
844
+
845
+ /**
846
+ * The tests of the explainer's runtime on the content in `root`/src: the exercises of each type, the videos, the tutor
847
+ * ways, the start page, the self-assessments, what is stored. The template folder and the fixtures of
848
+ * lernapps/app-templates run them (e2e/runtime.e2e.ts); an app does not, its scaffold has no such file: they test the
849
+ * runtime, which is the same in every app, not the app's content.
850
+ */
851
+ export function explainerRuntimeTests(root = "."): void {
852
+ explainerSuite(root, "runtime");
853
+ }
854
+
855
+ /** Only at desktop width, in a test of what does not depend on the width; what a page shows is tested at 360 px too. */
856
+ const desktopOnly = (page: Page) => test.skip((page.viewportSize()?.width ?? 0) <= 400, "only at desktop width");
857
+
858
+ function explainerSuite(root: string, part: "content" | "runtime"): void {
832
859
  const app = outline(root);
860
+ const content = part === "content";
833
861
 
834
862
  test.describe("explainer", () => {
835
863
  test.describe.configure({ mode: "parallel" });
836
864
  test.use({ reducedMotion: "reduce" });
837
865
 
838
- test.describe("without JavaScript", () => {
839
- test.use({ javaScriptEnabled: false });
866
+ if (content)
867
+ test.describe("without JavaScript", () => {
868
+ test.use({ javaScriptEnabled: false });
840
869
 
841
- for (const lang of app.languages) {
842
- for (const item of app.pages[lang] ?? []) {
843
- test(`${item.url || "./"}: headings, sections, formulas, figures and anchors as in the content`, async ({
844
- page,
845
- }) => {
846
- await page.goto(`./${item.url}`);
847
- const main = page.locator("main");
848
- const headings = await main.locator("h1, h2, h3, h4, h5, h6").evaluateAll((elements) =>
849
- elements.map((element) => ({
850
- level: Number(element.tagName.slice(1)),
851
- text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
852
- })),
853
- );
854
- expect(headings, "the heading levels follow the nesting").toEqual(item.headings);
855
- for (const section of item.sections) {
856
- const heading = page.locator(`[id="${section.id}"]`);
857
- await expect(heading, `section ${section.id}`).toBeVisible();
858
- await expect(heading).toHaveText(section.title);
859
- }
860
- // the formulas of the content; an exercise component's no-JS task, from its describe, is not content
861
- const formulas = await main
862
- .locator("math")
863
- .evaluateAll((elements) => elements.filter((math) => !math.closest(".explainer-described")).length);
864
- expect(formulas, "one <math> per formula").toBe(item.formulas);
865
- for (const name of item.figures) {
866
- await expect(main.getByRole("img", { name, exact: true }).first(), `the figure "${name}"`).toBeVisible();
867
- }
868
- await expectAnchors(page);
869
- await expectLanguages(page, item, app.languages);
870
- await expectExercises(page, item);
871
- await expectNojs(page, item);
872
- await expectVideos(page, item);
873
- });
874
- }
870
+ for (const lang of app.languages) {
871
+ for (const item of app.pages[lang] ?? []) {
872
+ test(`${item.url || "./"}: headings, sections, formulas, figures and anchors as in the content`, async ({
873
+ page,
874
+ }) => {
875
+ await page.goto(`./${item.url}`);
876
+ const main = page.locator("main");
877
+ const headings = await main.locator("h1, h2, h3, h4, h5, h6").evaluateAll((elements) =>
878
+ elements.map((element) => ({
879
+ level: Number(element.tagName.slice(1)),
880
+ text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
881
+ })),
882
+ );
883
+ expect(headings, "the heading levels follow the nesting").toEqual(item.headings);
884
+ for (const section of item.sections) {
885
+ const heading = page.locator(`[id="${section.id}"]`);
886
+ await expect(heading, `section ${section.id}`).toBeVisible();
887
+ await expect(heading).toHaveText(section.title);
888
+ }
889
+ // the formulas of the content; an exercise component's no-JS task, from its describe, is not content
890
+ const formulas = await main
891
+ .locator("math")
892
+ .evaluateAll((elements) => elements.filter((math) => !math.closest(".explainer-described")).length);
893
+ expect(formulas, "one <math> per formula").toBe(item.formulas);
894
+ for (const name of item.figures) {
895
+ await expect(
896
+ main.getByRole("img", { name, exact: true }).first(),
897
+ `the figure "${name}"`,
898
+ ).toBeVisible();
899
+ }
900
+ await expectAnchors(page);
901
+ await expectLanguages(page, item, app.languages);
902
+ await expectExercises(page, item);
903
+ await expectNojs(page, item);
904
+ await expectVideos(page, item);
905
+ });
906
+ }
875
907
 
876
- for (const item of app.others[lang] ?? []) {
877
- test(`${item.url || "./"}: its heading, its links and anchors, its languages`, async ({ page }) => {
878
- await page.goto(`./${item.url}`);
879
- await expect(page.locator("main h1")).toHaveText(item.title);
880
- await expectAnchors(page);
881
- await expectLanguages(page, item, app.languages);
882
- // only a content page starts a tutor; the start page says what it is
883
- await expect(page.locator(".explainer-tutor"), "no way to the tutor here").toHaveCount(0);
884
- if (item.kind === "start") {
885
- await expect(page.locator(".explainer-tutor-about h2")).toHaveText(messageOf(item.lang, "tutorTitle"));
908
+ for (const item of app.others[lang] ?? []) {
909
+ test(`${item.url || "./"}: its heading, its links and anchors, its languages`, async ({ page }) => {
910
+ await page.goto(`./${item.url}`);
911
+ await expect(page.locator("main h1")).toHaveText(item.title);
912
+ await expectAnchors(page);
913
+ await expectLanguages(page, item, app.languages);
914
+ // only a content page starts a tutor; the start page says what it is
915
+ await expect(page.locator(".explainer-tutor"), "no way to the tutor here").toHaveCount(0);
916
+ if (item.kind === "start") {
917
+ await expect(page.locator(".explainer-tutor-about h2")).toHaveText(messageOf(item.lang, "tutorTitle"));
918
+ }
919
+ });
920
+ }
921
+
922
+ test(`${lang}: previous and next form one chain over all pages`, async ({ page }) => {
923
+ desktopOnly(page);
924
+ const order = (app.pages[lang] ?? []).map((item) => item.url);
925
+ const first = order[0];
926
+ if (first === undefined) throw new Error(`no page in ${lang}`);
927
+ const relative = (url: string) => {
928
+ const path = new URL(url).pathname;
929
+ const at = order.find((candidate) => path.endsWith(`/${candidate}`));
930
+ return at ?? path;
931
+ };
932
+ const forward: string[] = [];
933
+ await page.goto(`./${first}`);
934
+ for (let step = 0; step <= order.length; step++) {
935
+ forward.push(relative(page.url()));
936
+ const next = page.locator('a[rel="next"]');
937
+ if ((await next.count()) === 0) break;
938
+ // the next page before its links count: a large page may still load when the loop looks again
939
+ const here = page.url();
940
+ await next.click();
941
+ await page.waitForURL((url) => url.href !== here);
942
+ }
943
+ expect(forward, "next leads through every page in order").toEqual(order);
944
+ const backward: string[] = [];
945
+ for (let step = 0; step <= order.length; step++) {
946
+ backward.push(relative(page.url()));
947
+ const previous = page.locator('a[rel="prev"]');
948
+ if ((await previous.count()) === 0) break;
949
+ const here = page.url();
950
+ await previous.click();
951
+ await page.waitForURL((url) => url.href !== here);
886
952
  }
953
+ expect(backward, "previous leads back through every page").toEqual([...order].reverse());
887
954
  });
888
955
  }
889
-
890
- test(`${lang}: previous and next form one chain over all pages`, async ({ page }) => {
891
- const order = (app.pages[lang] ?? []).map((item) => item.url);
892
- const first = order[0];
893
- if (first === undefined) throw new Error(`no page in ${lang}`);
894
- const relative = (url: string) => {
895
- const path = new URL(url).pathname;
896
- const at = order.find((candidate) => path.endsWith(`/${candidate}`));
897
- return at ?? path;
898
- };
899
- const forward: string[] = [];
900
- await page.goto(`./${first}`);
901
- for (let step = 0; step <= order.length; step++) {
902
- forward.push(relative(page.url()));
903
- const next = page.locator('a[rel="next"]');
904
- if ((await next.count()) === 0) break;
905
- // the next page before its links count: a large page may still load when the loop looks again
906
- const here = page.url();
907
- await next.click();
908
- await page.waitForURL((url) => url.href !== here);
909
- }
910
- expect(forward, "next leads through every page in order").toEqual(order);
911
- const backward: string[] = [];
912
- for (let step = 0; step <= order.length; step++) {
913
- backward.push(relative(page.url()));
914
- const previous = page.locator('a[rel="prev"]');
915
- if ((await previous.count()) === 0) break;
916
- const here = page.url();
917
- await previous.click();
918
- await page.waitForURL((url) => url.href !== here);
919
- }
920
- expect(backward, "previous leads back through every page").toEqual([...order].reverse());
921
- });
922
- }
923
- });
956
+ });
924
957
 
925
958
  // with JavaScript in German, one exercise of each type per page; the other languages render the same code
926
959
  test.describe("with JavaScript", () => {
927
960
  // at the narrowest width only, where the feedback is hardest to keep in view: the exercises' logic is the same
928
961
  // at every width, and the hooks stay fast
929
962
  test.skip(({ viewport }) => (viewport?.width ?? 0) > 400, "the exercises run at 360 px");
930
- for (const item of app.pages["de"] ?? []) {
931
- const types = new Map<string, ExerciseOutline>();
932
- for (const exercise of item.exercises) if (!types.has(exercise.type)) types.set(exercise.type, exercise);
933
- if (types.size === 0) continue;
934
- test(`${item.url}: one exercise of each type checks its answers (${[...types.keys()].join(", ")})`, async ({
935
- page,
936
- }) => {
937
- for (const exercise of types.values()) {
938
- if (exercise.kind === "question") await checkQuestion(page, item.url, exercise);
939
- else if (exercise.kind === "open") await checkOpenTask(page, item.url, exercise);
940
- else await checkGenerated(page, item.url, exercise);
941
- }
942
- // only an open task keeps something, its answer, and only while the tab is open
943
- expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
944
- const kept = await page.evaluate(() => Object.keys(sessionStorage));
945
- expect(
946
- kept.filter((key) => !/^lernapps:[^:]+:antwort:/.test(key)),
947
- "nothing else in the tab",
948
- ).toEqual([]);
949
- expect(kept.length, "an answer per open task").toBe(types.has("open-task") ? 1 : 0);
950
- });
951
- }
952
- for (const item of app.pages["de"] ?? []) {
953
- if (item.videos.length === 0) continue;
954
- test(`${item.url}: the videos load nothing before a click, the player after it, directly once chosen`, async ({
955
- page,
956
- baseURL,
957
- }) => {
958
- await checkVideos(page, item, new URL(baseURL ?? page.url()).origin);
959
- });
960
- }
961
- for (const item of app.pages["de"] ?? []) {
962
- if (item.components.length === 0) continue;
963
- test(`${item.url}: each component hydrates without a console error (${item.components.map((component) => component.name).join(", ")})`, async ({
964
- page,
965
- }) => {
966
- await checkComponents(page, item);
967
- expect(await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)])).toEqual([]);
968
- });
969
- }
963
+ if (!content)
964
+ for (const item of app.pages["de"] ?? []) {
965
+ const types = new Map<string, ExerciseOutline>();
966
+ for (const exercise of item.exercises) if (!types.has(exercise.type)) types.set(exercise.type, exercise);
967
+ if (types.size === 0) continue;
968
+ test(`${item.url}: one exercise of each type checks its answers (${[...types.keys()].join(", ")})`, async ({
969
+ page,
970
+ }) => {
971
+ for (const exercise of types.values()) {
972
+ if (exercise.kind === "question") await checkQuestion(page, item.url, exercise);
973
+ else if (exercise.kind === "open") await checkOpenTask(page, item.url, exercise);
974
+ else await checkGenerated(page, item.url, exercise);
975
+ }
976
+ // only an open task keeps something, its answer, and only while the tab is open
977
+ expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
978
+ const kept = await page.evaluate(() => Object.keys(sessionStorage));
979
+ expect(
980
+ kept.filter((key) => !/^lernapps:[^:]+:antwort:/.test(key)),
981
+ "nothing else in the tab",
982
+ ).toEqual([]);
983
+ expect(kept.length, "an answer per open task").toBe(types.has("open-task") ? 1 : 0);
984
+ });
985
+ }
986
+ if (!content)
987
+ for (const item of app.pages["de"] ?? []) {
988
+ if (item.videos.length === 0) continue;
989
+ test(`${item.url}: the videos load nothing before a click, the player after it, directly once chosen`, async ({
990
+ page,
991
+ baseURL,
992
+ }) => {
993
+ await checkVideos(page, item, new URL(baseURL ?? page.url()).origin);
994
+ });
995
+ }
996
+ if (content)
997
+ for (const item of app.pages["de"] ?? []) {
998
+ if (item.components.length === 0) continue;
999
+ test(`${item.url}: each component hydrates without a console error (${item.components.map((component) => component.name).join(", ")})`, async ({
1000
+ page,
1001
+ }) => {
1002
+ await checkComponents(page, item);
1003
+ expect(await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)])).toEqual(
1004
+ [],
1005
+ );
1006
+ });
1007
+ }
970
1008
  });
971
1009
 
1010
+ // everything below tests the runtime
1011
+ if (content) return;
1012
+
972
1013
  for (const item of (app.pages["de"] ?? []).filter((candidate) =>
973
1014
  candidate.exercises.some((exercise) => exercise.kind === "generated"),
974
1015
  )) {