diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 85c2d8ee..4f42caac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -368,10 +368,12 @@ jobs:
# Chromium, logging in to a Community Solid Server 7 behind TLS that the
# harness starts in Docker, each journey with an account and pod of its
# own. The other engines and the phone run a few of them
- # (docs/testing.md#browsers). Each browser's journeys are split in
- # SHARDS jobs (npx playwright --shard), side by side, so the run waits
- # for one shard's share of them, not for all: each shard pays the
- # setup again (about a minute), for a shorter wall time. When one
+ # (docs/testing.md#browsers). Five jobs run them side by side, so the
+ # run waits for the slowest, not for all: Chromium's journeys in two
+ # shards (npx playwright --shard), and each other engine's in a job of
+ # its own, so that WebKit's and the phone's, the slowest, never land
+ # together. Each job pays the setup again (about a minute), for a
+ # shorter wall time. When one
# fails, the job's summary names the step it failed in, and its
# artifact holds the trace to replay it step
# by step (`npx playwright show-trace`), the report, a video, and the
@@ -383,33 +385,45 @@ jobs:
# on a shared runner. Their waits are fixed where they flake, not
# retried (retries: 0, playwright.config.ts).
journeys:
- name: journeys (${{ matrix.name }}, ${{ matrix.shard }}/2)
+ name: journeys (${{ matrix.name }})
needs: gate
if: needs.gate.outputs.jobs == 'all'
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
+ # name: the job's, and its blob's (journeys-report lists them);
+ # projects: what it runs; browsers: what it installs, the build
+ # drawing the docs' diagrams in Chromium's headless shell; cache: the
+ # key its browsers are kept under, the phone's being WebKit's.
matrix:
- name: [chromium, engines]
- # 1 to SHARDS, which journeys-report's env and the name above
- # repeat.
- shard: [1, 2]
include:
- - name: chromium
- projects: --project=chromium
+ - name: chromium-1
+ projects: --project=chromium --shard=1/2
browsers: chromium
- # The build draws the docs' diagrams in Chromium's headless shell.
- - name: engines
- projects: --project=firefox --project=webkit --project=phone
- browsers: chromium-headless-shell firefox webkit
+ cache: chromium
+ - name: chromium-2
+ projects: --project=chromium --shard=2/2
+ browsers: chromium
+ cache: chromium
+ - name: firefox
+ projects: --project=firefox
+ browsers: chromium-headless-shell firefox
+ cache: firefox
+ - name: webkit
+ projects: --project=webkit
+ browsers: chromium-headless-shell webkit
+ cache: webkit
+ - name: phone
+ projects: --project=phone
+ browsers: chromium-headless-shell webkit
+ cache: webkit
env:
JOURNEY_TRACE: >-
${{ (inputs.trace || github.ref == 'refs/heads/main' ||
github.event_name == 'merge_group') && 'on' || '' }}
- SHARDS: 2
# The blob's name, so the jobs' blobs sit side by side when merged.
- PLAYWRIGHT_BLOB_OUTPUT_NAME: report-${{ matrix.name }}-${{ matrix.shard }}.zip
+ PLAYWRIGHT_BLOB_OUTPUT_NAME: report-${{ matrix.name }}.zip
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -425,7 +439,7 @@ jobs:
id: browsers
with:
path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-${{ matrix.name }}
+ key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-${{ matrix.cache }}
- if: steps.browsers.outputs.cache-hit != 'true'
run: npx playwright install --with-deps ${{ matrix.browsers }}
- if: steps.browsers.outputs.cache-hit == 'true'
@@ -447,14 +461,14 @@ jobs:
with:
path: ${{ steps.images.outputs.file }}
key: ${{ steps.images.outputs.key }}
- - run: npm run journeys -- ${{ matrix.projects }} --shard="${{ matrix.shard }}/$SHARDS"
+ - run: npm run journeys -- ${{ matrix.projects }}
- if: always()
- run: node e2e/journeys/harness/summary.ts journeys-${{ matrix.name }}-${{ matrix.shard }}-${{ github.run_attempt }}
+ run: node e2e/journeys/harness/summary.ts journeys-${{ matrix.name }}-${{ github.run_attempt }}
# In the queue, the blob below holds the traces; this, only on a failure.
- if: failure() || (env.JOURNEY_TRACE == 'on' && github.event_name != 'merge_group')
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
- name: journeys-${{ matrix.name }}-${{ matrix.shard }}-${{ github.run_attempt }}
+ name: journeys-${{ matrix.name }}-${{ github.run_attempt }}
path: |
e2e/journeys/playwright-report/
e2e/journeys/test-results/
@@ -466,7 +480,7 @@ jobs:
- if: always() && (github.ref == 'refs/heads/main' || github.event_name == 'merge_group')
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
- name: journeys-blob-${{ matrix.name }}-${{ matrix.shard }}
+ name: journeys-blob-${{ matrix.name }}
path: e2e/journeys/blob-report/
retention-days: 7
overwrite: true
@@ -476,7 +490,7 @@ jobs:
# the deploy publishes it at /journeys/ (docs/deployment.md). When this
# run skipped the journeys, their blobs come from the run gate names,
# which ran them on this tree: the merge queue's, or a release commit's
- # parent's. When the journeys passed, or ran there, every shard's blob
+ # parent's. When the journeys passed, or ran there, every job's blob
# must be there, so a run the deploy may publish never has a report
# missing a browser. ci-ok doesn't need it, as it runs on main alone;
# its failure still fails the run, and so holds back release and
@@ -517,14 +531,12 @@ jobs:
env:
JOURNEYS: ${{ needs.journeys.result }}
JOBS: ${{ needs.gate.outputs.jobs }}
- # As journeys' matrix has them.
- SHARDS: 2
+ # The journeys jobs' names, as their matrix has them.
+ REPORTS: chromium-1 chromium-2 firefox webkit phone
run: |
if [ "$JOURNEYS" = success ] || [ "$JOBS" = deterministic ]; then
- for name in chromium engines; do
- for shard in $(seq "$SHARDS"); do
- test -f "blob-reports/report-$name-$shard.zip"
- done
+ for name in $REPORTS; do
+ test -f "blob-reports/report-$name.zip"
done
fi
if compgen -G 'blob-reports/*.zip' > /dev/null; then
@@ -596,7 +608,8 @@ jobs:
echo "images=$(jq -cn '$ARGS.positional' --args "${images[@]}")" >> "$GITHUB_OUTPUT"
version=$(node -p "require('@playwright/test/package.json').version")
browsers=()
- for name in chromium engines; do
+ # The journeys matrix's caches.
+ for name in chromium firefox webkit; do
cached "playwright-$RUNNER_OS-$version-$name" || browsers+=("$name")
done
echo "Browsers missing: ${browsers[*]:-none}"
@@ -687,10 +700,11 @@ jobs:
- id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- run: |
+ # As the journeys matrix installs them for this cache.
if [ "$BROWSERS" = chromium ]; then
npx playwright install chromium
else
- npx playwright install chromium-headless-shell firefox webkit
+ npx playwright install chromium-headless-shell "$BROWSERS"
fi
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
diff --git a/docs/accessibility.md b/docs/accessibility.md
index 785eefb1..f2f6c6fd 100644
--- a/docs/accessibility.md
+++ b/docs/accessibility.md
@@ -46,8 +46,8 @@ but the journeys:
- **Every screen a journey visits**: the
[user journeys](testing.md#user-journeys) run
[axe](https://github.com/dequelabs/axe-core) on the screen each step
- ends on, in the light and the dark theme, in each browser a journey
- runs in (on a phone too), with its rules for WCAG 2.0,
+ ends on, in the light and the dark theme, in Chromium and on the
+ phone (WebKit), with its rules for WCAG 2.0,
2.1 and 2.2 at levels A and AA. A violation fails the journey, and the
report says which rule, on which element, with what axe found
([`harness/accessibility.ts`](../e2e/journeys/harness/accessibility.ts)).
@@ -100,7 +100,10 @@ and an issue for one is welcome ([below](#report-a-barrier)).
- **Browsers**: every journey runs in Chromium, but only those that
differ between engines run in Firefox, WebKit and on a phone
([testing.md](testing.md#browsers)), so axe sees the other screens
- in Chromium alone.
+ in Chromium alone. Desktop Firefox and WebKit run no axe at all: the
+ check takes about 40% of a journey's time, and the phone already
+ checks WebKit's rendering. What only Firefox computes, such as the
+ contrast of a color it mixes (`color-mix`), goes unchecked.
- **The graph**: the Studio's graph of an instance is drawn as one
image, its nodes reached one by one with the arrow keys, by where
they are drawn, not along their links; the edges to vocabulary
diff --git a/docs/statistics.md b/docs/statistics.md
index 816fdae6..10be3158 100644
--- a/docs/statistics.md
+++ b/docs/statistics.md
@@ -436,8 +436,8 @@ rest of their study when they log in.
and not as recall, and the chapter just completed under "Your
courses", done, with its graded answers; and step 10 that the
course's deck names the wrong options chosen in its final review. In
- [studio.journey.ts](../e2e/journeys/journeys/studio.journey.ts),
- steps 30 and 32 check that trying a course in the Studio's sandbox
+ [studio-course.journey.ts](../e2e/journeys/journeys/studio-course.journey.ts),
+ steps 07 and 09 check that trying a course in the Studio's sandbox
leaves the statistics as they were. The page object is
[Statistics.ts](../e2e/journeys/pages/Statistics.ts). The latest
run's screenshots are online at .
diff --git a/docs/testing.md b/docs/testing.md
index 32616908..38dd9e73 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -359,7 +359,7 @@ Further journeys go through:
Every journey runs in desktop Chromium, the `chromium` project in
[playwright.config.ts](../e2e/journeys/playwright.config.ts). Three
-more projects run only the journeys tagged `@smoke`, `@guest` and
+more projects run only the journeys tagged `@guest` and
`@reload`: `firefox` (desktop Firefox), `webkit` (desktop Safari's
engine) and `phone` (an iPhone 14's screen and touch, on WebKit). Those
journeys go through what differs most between engines: the login's
@@ -368,14 +368,24 @@ browser's storage through them, the silent restore of a session after a
reload, and a page narrow enough to lay out for a phone. The other
journeys test the app's logic, which is the same in every engine, so
running them all in each would about triple the time for little gain. A
-journey tagged with one of the three runs in every browser; tag another
-only when what it tests differs between engines. CI runs Chromium's
-journeys apart from the other three projects', each split in two shards
-(`npx playwright test --shard`), four jobs side by side:
-`journeys (chromium, 1/2)` to `journeys (engines, 2/2)`. The run waits
-for the slowest shard, about half of what one job took, and each shard
-pays the job's setup again, about a minute. Playwright shards Chromium's
-journeys by file, so a file's journeys stay in one shard. The [user guide](user-guide.md#supported-browsers)
+journey tagged with one of the two runs in every browser; tag another
+only when what it tests differs between engines. A step of such a
+journey that tests the app's logic again names the projects it runs in
+(`app.step(title, body, { in: ["chromium"] })`), and the report shows
+it skipped in the others, as the reload journey's statistics and the
+shared link's import are. CI runs them in five
+jobs side by side: Chromium's journeys in two shards
+(`npx playwright test --shard`), `journeys (chromium-1)` and
+`journeys (chromium-2)`, and each other project's in a job of its own,
+`journeys (firefox)`, `journeys (webkit)` and `journeys (phone)`. The
+run waits for the slowest job, and each job pays the setup again, about
+a minute. Chromium's journeys run fully parallel
+(`fullyParallel: true`), each a unit the shards and workers share out,
+so a file of several journeys, such as `pod-safety`, is spread over
+both shards; a file whose journeys depend on each other runs them in
+turn (`test.describe.configure({ mode: "serial" })`). A job a project makes its own, rather than a shard of all
+three, keeps WebKit's and the phone's journeys, the slowest, from
+landing in one job. The [user guide](user-guide.md#supported-browsers)
says which browsers Solid Memo supports.
The engines differ in three ways the journeys allow for:
@@ -414,8 +424,11 @@ are.
**Accessibility.** Each step's end checks the screen it leaves the app
on with [axe](https://github.com/dequelabs/axe-core), in the light and
the dark theme, against WCAG 2.0, 2.1 and 2.2 at levels A and AA
-(`App.step`, [harness/accessibility.ts](../e2e/journeys/harness/accessibility.ts)).
-A violation fails the journey, which goes on to its end so that every
+(`App.step`, [harness/accessibility.ts](../e2e/journeys/harness/accessibility.ts)),
+in a step of its own, "Accessibility", within the journey's step, so the
+report shows how long the check took. Chromium and the phone run it;
+desktop Firefox and WebKit leave it out, as their projects' metadata
+says (`accessibility: false`, [accessibility.md](accessibility.md)). A violation fails the journey, which goes on to its end so that every
screen is checked. A violation let pass is listed in that file's
`KNOWN_EXCEPTIONS`, with its reason. The Solid server's own pages, its
login, are not checked. What the app aims for, and what else is
@@ -447,7 +460,7 @@ error, and the browser it failed in. The job's
published with each deploy: the report of the CI run on the release
commit the site runs (none after a deploy by hand), every browser's
journeys in one, as the `journeys-report` job merges them from every
-journeys job (Playwright's blob reports, one a shard). As the release commit's run
+journeys job (Playwright's blob reports, one a job). As the release commit's run
skips the journeys, it takes their reports from the run that ran them
on its code, usually the merge queue's
([releasing.md](releasing.md#what-each-stage-checks)). It shows every
@@ -517,8 +530,9 @@ test("a learner's first deck @smoke", async ({ app, account, runId }) => {
([test/dialogs.ts](../apps/studio/src/test/dialogs.ts)).
- **One after another**: a journey that needs what another made is in
its file, after it, and the file runs in turn (`mode: "serial"`): the
- course the Studio journey publishes is added from its link by the next
- journey in [studio.journey.ts](../e2e/journeys/journeys/studio.journey.ts),
+ course the Studio's course journey publishes is added from its link by
+ the next journey in
+ [studio-course.journey.ts](../e2e/journeys/journeys/studio-course.journey.ts),
which is skipped when the first fails.
The harness's own code (`harness/`) has unit tests (`npm test`, so `npm
@@ -564,8 +578,8 @@ jobs, side by side, each [deterministic or timing-dependent](#deterministic-and-
| `crosscheck` | deterministic | the Python scripts' tests (the cross-check's and the taxonomy deck generator's), then the vocabulary, the deck library and the pod catalog documents, by pySHACL ([validation.md](validation.md#the-ci-cross-check)) |
| `pod ()` | timing-dependent | the end-to-end tests, one job per blocking server (above) |
| `contract ()` | timing-dependent | that each advisory server starts and meets the contract |
-| `journeys (, )` | timing-dependent | the user journeys: every one in Chromium, and those that differ between engines in Firefox, WebKit and on a phone, each browser's in two shards ([Browsers](#browsers)) |
-| `journeys-report` | deterministic | nothing: on `main`, after the `journeys` jobs, even when one failed, it merges their runs into one report, the `journeys-report` artifact the deploy publishes at `/journeys/` ([User journeys](#user-journeys)); when this run skipped them, their runs come from the run that tested its code (the gate's `tested-by`). When the journeys passed, or were skipped, it fails without every shard's report; its failure fails the run, so it holds back the release and the deploy, but not `ci-ok` |
+| `journeys ()` | timing-dependent | the user journeys: every one in Chromium, in two shards, and those that differ between engines in Firefox, WebKit and on a phone, a job each ([Browsers](#browsers)) |
+| `journeys-report` | deterministic | nothing: on `main`, after the `journeys` jobs, even when one failed, it merges their runs into one report, the `journeys-report` artifact the deploy publishes at `/journeys/` ([User journeys](#user-journeys)); when this run skipped them, their runs come from the run that tested its code (the gate's `tested-by`). When the journeys passed, or were skipped, it fails without every job's report; its failure fails the run, so it holds back the release and the deploy, but not `ci-ok` |
| `docs` | deterministic | the docs' links, every file's license, and `publiccode.yml` ([Documentation](#documentation)); their style and spelling are `build`'s |
| `workflows` | deterministic | the workflows and local actions, by actionlint and zizmor (`npm run lint:workflows`, [The workflows](#the-workflows)) |
| `commits` | deterministic | on a pull request, that each commit it adds follows Conventional Commits, by commitlint ([CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages)), and that its body or a commit closes its issue, or the body says it is one step of it (`npm run check:closes`; not for Renovate's); in the merge queue or on a push to `main`, nothing |
diff --git a/docs/user-guide.md b/docs/user-guide.md
index fe58538d..3641f810 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -455,14 +455,15 @@ screenshot of each.
| The course for newcomers, from an empty deck list | [newcomer-course.journey.ts](../e2e/journeys/journeys/newcomer-course.journey.ts) | 03–05 |
| A course's first chapter and its final review | [course-chapter.journey.ts](../e2e/journeys/journeys/course-chapter.journey.ts) | 03–08 |
| A course's answers and chapters in the statistics, and its most-confused options | course-chapter | 09–10 |
-| Adding a course from a link and playing it | [studio.journey.ts](../e2e/journeys/journeys/studio.journey.ts), second journey | 03–05 |
-| Solid Memo Studio | studio.journey.ts, first journey | 06–36 |
+| Adding a course from a link and playing it | [studio-course.journey.ts](../e2e/journeys/journeys/studio-course.journey.ts), second journey | 03–05 |
+| Solid Memo Studio | [studio.journey.ts](../e2e/journeys/journeys/studio.journey.ts) | 06–31 |
+| Writing a course in the Studio, trying it and publishing it | studio-course.journey.ts, first journey | 05–11 |
| Trying the app as a guest, and moving the study into a pod | [guest.journey.ts](../e2e/journeys/journeys/guest.journey.ts) | 02–11 |
| Copying a library deck's link, and opening it in another browser as a guest | [try-link.journey.ts](../e2e/journeys/journeys/try-link.journey.ts) | 02–05 |
| A screen that breaks: Something went wrong, its details and its report | [render-error.journey.ts](../e2e/journeys/journeys/render-error.journey.ts) | 02–06 |
| The dark theme, kept in the Pod and found on a second device | [returning-learner.journey.ts](../e2e/journeys/journeys/returning-learner.journey.ts) | 03–04, 08–10 |
| A second instance, and switching between the two | [second-instance.journey.ts](../e2e/journeys/journeys/second-instance.journey.ts) | 04–08 |
-| Exporting a deck as a file and importing it again, with its progress | studio.journey.ts, first journey | 21–23 |
+| Exporting a deck as a file and importing it again, with its progress | studio.journey.ts | 21–23 |
| Switching the scheduler to FSRS-6 from the deck list's invitation, then reviewing with it | [later-day.journey.ts](../e2e/journeys/journeys/later-day.journey.ts) | 03, 06–09 |
| Reviewing on a later study day, forgetting cards, and the hardest cards | later-day | 07–12 |
| Updating a library deck to its new release, its progress kept | [library-upgrade.journey.ts](../e2e/journeys/journeys/library-upgrade.journey.ts) | 04–10 |
diff --git a/e2e/journeys/journeys/studio-course.journey.ts b/e2e/journeys/journeys/studio-course.journey.ts
new file mode 100644
index 00000000..bbeae22f
--- /dev/null
+++ b/e2e/journeys/journeys/studio-course.journey.ts
@@ -0,0 +1,235 @@
+// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
+//
+// SPDX-License-Identifier: MIT
+
+import { expect } from "@playwright/test";
+import { test } from "../fixtures.ts";
+import { logInAndCreateInstance } from "../flows/logIn.ts";
+
+// The second journey adds what the first publishes: they run in turn, the second only after the first passed.
+test.describe.configure({ mode: "serial" });
+
+/** The course the first journey published, at its address: what the next journey adds from a link. */
+let published: { url: string; title: string } | null = null;
+
+/** The course the first journey writes: its one chapter, its step's theory, and its questions by their right answers. */
+const COURSE = {
+ chapter: "Pods",
+ theory: "A pod is where your data lives.",
+ answers: { "Where does your data live?": "In a pod", "Who chooses the app?": "You do" },
+};
+
+/**
+ * Writing a course in Solid Memo Studio (docs/studio.md), from a blank
+ * draft to its release in the user's pod. A user logs in, creates an
+ * instance, makes a deck with a card and studies it, and opens the
+ * instance in the Studio. They write a course in a blank
+ * draft: a chapter, a step with its theory, and two questions, the first
+ * with two wrong options, saved as they go, trying one question as the
+ * course would ask it. Its release check lists what is still wrong: the
+ * second question has no wrong options and the release no description.
+ * They follow each problem to its field, fix it, and see it clear; the
+ * shapes find what a release still lacks, and the listing preview shows
+ * the course as the library will. They note what Solid Memo's statistics
+ * count, then test-play the course in the Studio's trial, to the end of
+ * its one chapter's final review. The instance, and Solid Memo's
+ * statistics, are as they were: the trial is played in a sandbox. They
+ * say how the course was made, then publish it to their pod, saying it
+ * will be public: someone with no login reads it, and the releases list
+ * it. Managing an instance's decks in the Studio is studio.journey.ts's.
+ */
+test("write a course in the Studio, try it, and publish it to the pod @studio", async ({
+ app,
+ account,
+ runId,
+ playwright,
+}) => {
+ const instance = `Courses ${runId}`;
+ const deck = `Studied ${runId}`;
+ const authored = `Authored ${runId}`;
+
+ await app.step("01 · Visit Solid Memo", () => app.onboarding.visit());
+ await app.step("02 · Log in and create an instance", () =>
+ logInAndCreateInstance(app, account, instance),
+ );
+ // Something studied, for the statistics the trial must leave as they were.
+ await app.step("03 · Create a deck with a card, and study it", async () => {
+ await app.decks.openDeckCreator();
+ await app.deckCreator.create(deck);
+ await app.deckBrowser.openFor(deck);
+ await app.deckBrowser.openCardCreator();
+ await app.cardCreator.addCards([["Sun", "Sol"]], { front: "en", back: "sv" });
+ await app.cardCreator.back();
+ await app.chrome.breadcrumb("breadcrumbs.decks");
+ await app.decks.study(deck);
+ await app.study.studyAll({ grade: "good" });
+ await app.study.endSession();
+ });
+ await app.step("04 · Open the instance in Solid Memo Studio", () => app.studio.openFromApp());
+ await app.step(
+ "05 · Write a course: a chapter, a step, and two questions with two wrong options each",
+ async () => {
+ await app.studio.openDrafts(instance);
+ await app.studio.draftCourse(instance, authored);
+ await app.draftEditor.openDraft(authored);
+ await app.draftEditor.addChapter("Pods");
+ await app.draftEditor.openChapter("Pods");
+ await app.draftEditor.addStep();
+ await app.draftEditor.writeTheory("A pod is where your data lives.");
+ await app.draftEditor.addQuestion("Where does your data live?", "In a pod");
+ await app.draftEditor.addQuestion("Who chooses the app?", "You do");
+ await app.draftEditor.openQuestion("Where does your data live?");
+ await app.draftEditor.addWrongOption("In the app", "Apps only read it.");
+ await app.draftEditor.addWrongOption("On a server of the app's", "The pod is yours.");
+ await app.draftEditor.answerPreviewWrong("In the app", "Apps only read it.");
+ await app.draftEditor.expectOutline(authored, "Pods", "Step 1.1", [
+ "Where does your data live?",
+ "Who chooses the app?",
+ ]);
+ await app.draftEditor.expectBadge("Who chooses the app?", 1);
+ },
+ );
+
+ await app.step(
+ "06 · Check the release: follow its problems to their fields, fix them, and see them clear",
+ async () => {
+ const fewOptions = app.t("studio.problem.fewDistractors", { count: 0, least: 2 });
+ const noDescription = app.t("studio.problem.required", {
+ field: app.t("studio.field.description"),
+ });
+ const release = app.t("studio.check.release");
+ await app.draftEditor.openCheck();
+ await app.draftEditor.expectProblem("Who chooses the app?", fewOptions);
+ await app.draftEditor.expectProblem(release, noDescription);
+ await app.draftEditor.followProblem("Who chooses the app?", fewOptions);
+ await app.draftEditor.addWrongOption("The pod", "A pod holds data.");
+ await app.draftEditor.addWrongOption("Its maker", "The user chooses.");
+ await app.draftEditor.openCheck();
+ await app.draftEditor.expectNoProblem("Who chooses the app?", fewOptions);
+ await app.draftEditor.followProblem(release, noDescription);
+ await app.draftEditor.writeDescription("What a pod is, and who holds the data in it.");
+ await app.draftEditor.openCheck();
+ await app.draftEditor.expectNoProblem(release, noDescription);
+ await app.draftEditor.checkShapes();
+ await app.draftEditor.openPreview(authored);
+ await app.chrome.breadcrumb("breadcrumbs.decks");
+ },
+ );
+
+ const counted: Record = {};
+ const tiles = ["answers", "studyDays", "cards"] as const;
+ await app.step("07 · Note what Solid Memo's statistics count", async () => {
+ await app.studio.backToApp();
+ await app.statistics.open();
+ for (const tile of tiles) counted[tile] = await app.statistics.value(tile);
+ });
+
+ await app.step(
+ "08 · Test-play the course in the Studio's trial, to the end of its final review",
+ async () => {
+ await app.studio.openFromApp();
+ await app.studio.openDrafts(instance);
+ await app.draftEditor.openDraft(authored);
+ await app.trial.open(authored);
+ await app.trial.playToTheEnd(COURSE.chapter, COURSE.theory, COURSE.answers);
+ },
+ );
+
+ await app.step(
+ "09 · The instance's decks and Solid Memo's statistics are as they were",
+ async () => {
+ await app.chrome.breadcrumb("breadcrumbs.decks");
+ await app.studio.expectDeck(instance, deck, { cards: 1, due: 0 });
+ await app.studio.expectNoDeck(instance, authored);
+ await app.studio.backToApp();
+ await app.statistics.open();
+ for (const tile of tiles) expect(await app.statistics.value(tile), tile).toBe(counted[tile]);
+ },
+ );
+
+ await app.step(
+ "10 · Say how the course was made: its author, an attribution, its license, a source and an AI check; follow the source's warning and fix it",
+ async () => {
+ const source = "https://example.org/pods-explained";
+ const undescribed = app.t("studio.problem.sourceUndescribed", {
+ missing: app.t("studio.release.detail.creator"),
+ });
+ await app.studio.openFromApp();
+ await app.studio.openDrafts(instance);
+ await app.draftEditor.openDraft(authored);
+ await app.draftEditor.openRelease();
+ await app.draftEditor.addAuthor("Journey Author");
+ await app.draftEditor.attributeWithAi("Journey Author");
+ await app.draftEditor.chooseReleaseLicense("CC0 1.0");
+ await app.draftEditor.addSource(source, "Pods explained", "The page's footer: CC0.");
+ await app.draftEditor.recordAiCheck("Facts", "every question", "none wrong");
+ await app.draftEditor.openCheck();
+ await app.draftEditor.expectProblem(source, undescribed);
+ await app.draftEditor.followProblem(source, undescribed);
+ await app.draftEditor.describeSource(source, "Pods explained", "Example authors");
+ await app.draftEditor.openCheck();
+ await app.draftEditor.expectNoProblem(source, undescribed);
+ },
+ );
+
+ await app.step(
+ "11 · Publish the course to the pod, public: anyone can read it, and the releases list it",
+ async () => {
+ await app.draftEditor.openRelease();
+ await app.draftEditor.choosePublisher("Journey Author");
+ const url = await app.draftEditor.publish();
+ // Someone with no login reads it.
+ const anyone = await playwright.request.newContext();
+ const read = await anyone.get(url, { headers: { accept: "text/turtle" } });
+ expect(read.status()).toBe(200);
+ expect(await read.text()).toContain(`"${authored}"@en`);
+ await anyone.dispose();
+ await app.studio.openReleases(instance);
+ await app.studio.expectRelease(instance, url, `authored-${runId}`, 1);
+ published = { url, title: authored };
+ },
+ );
+});
+
+/**
+ * A course published in a pod, added from its link by someone else
+ * (docs/deck-library.md, From a link): another user, with an account and
+ * a pod of their own, opens "Add from a link" from their empty deck list
+ * and pastes the address of the course the journey before published.
+ * The release is read and shown, with the host it is published on, as a
+ * course. They start it, and play its chapter to the end of its final
+ * review, as a library course is played. Their deck list then says where
+ * the course came from.
+ */
+test("add a course published in a pod from its link, as another user, and play its chapter @studio", async ({
+ app,
+ account,
+ runId,
+}) => {
+ const course = published;
+ expect(course, "the journey just before publishes the course").not.toBeNull();
+ const { url, title } = course!;
+
+ await app.step("01 · Visit Solid Memo", () => app.onboarding.visit());
+ await app.step("02 · Log in as another user and create an instance", () =>
+ logInAndCreateInstance(app, account, `Learner ${runId}`),
+ );
+ await app.step(
+ "03 · Paste the course's link: what it is, and where it is published",
+ async () => {
+ await app.importUrl.openFromDeckList();
+ await app.importUrl.showCourse(url, title);
+ },
+ );
+ await app.step(
+ "04 · Start the course and play its chapter to the end of its final review",
+ async () => {
+ await app.importUrl.startCourse(title);
+ await app.course.playChapter(COURSE.chapter, COURSE.theory, COURSE.answers);
+ },
+ );
+ await app.step("05 · Back at Decks: the course says where it came from", async () => {
+ await app.chrome.breadcrumb("breadcrumbs.decks");
+ await app.decks.expectDeckFrom(title, new URL(url).host);
+ });
+});
diff --git a/e2e/journeys/journeys/studio.journey.ts b/e2e/journeys/journeys/studio.journey.ts
index 4f768fe4..3c5d1701 100644
--- a/e2e/journeys/journeys/studio.journey.ts
+++ b/e2e/journeys/journeys/studio.journey.ts
@@ -4,22 +4,8 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures.ts";
-import { logInAndCreateInstance } from "../flows/logIn.ts";
import { BRIGHTEST_STARS } from "../pages/Library.ts";
-// The second journey adds what the first publishes: they run in turn, the second only after the first passed.
-test.describe.configure({ mode: "serial" });
-
-/** The course the Studio journey published, at its address: what the next journey adds from a link. */
-let published: { url: string; title: string } | null = null;
-
-/** The course the Studio journey writes: its one chapter, its step's theory, and its questions by their right answers. */
-const COURSE = {
- chapter: "Pods",
- theory: "A pod is where your data lives.",
- answers: { "Where does your data live?": "In a pod", "Who chooses the app?": "You do" },
-};
-
/**
* Solid Memo Studio in Solid Memo's page (docs/studio.md): a visitor
* opens the Studio's old address, /studio/, which lands on the Studio's
@@ -53,26 +39,15 @@ const COURSE = {
* export one of them as a Turtle file, with their progress, delete it,
* once the user confirms, and import it again from the file, its card
* due as before. They draft a release of a deck and a blank course,
- * which the sidebar lists, and delete the course's draft, confirming. They
- * write a course in a blank draft: a chapter, a step with its theory,
- * and two questions, the first with two wrong options, saved as they
- * go, trying one question as the course would ask it. Its release check
- * lists what is still wrong: the second question has no wrong options
- * and the release no description. They follow each problem to its field,
- * fix it, and see it clear; the shapes find what a release still lacks,
- * and the listing preview shows the course as the library will. They rename the instance
+ * which the sidebar lists, and delete the course's draft, confirming.
+ * They rename the instance
* and describe its catalog, under a license, then pick it, by its new
* name, from the picker. Among its library copies, the library deck is
- * up to date. They note what Solid Memo's statistics count, then
- * test-play the course they wrote in the Studio's trial, to the end of
- * its one chapter's final review. The instance's decks, and Solid
- * Memo's statistics, are as they were: the trial is played in a sandbox.
+ * up to date.
* They draft the next version of the library deck, by its address,
* retire one of its cards, and compare the draft with the release: it
* keeps the series' rules, lists the card retired, and a learner's copy
- * would retire it too, losing nothing. They say how the course was made,
- * then publish it to their pod, saying it will be public: someone with no
- * login reads it, and the releases list it. They move down the library
+ * would retire it too, losing nothing. They move down the library
* deck's cards with the keyboard and back, one selected: the row with
* the focus stays in sight between the header row and the bar of the
* rows selected, which stick. They draw the instance as a graph, open
@@ -80,13 +55,13 @@ const COURSE = {
* draft of the next version follows: a leaf outside the pod, never
* fetched, shown with the draft that links to it as a table, and let go
* of it with a click on the drawing's background. They go back to Solid Memo, still
- * logged in, which lists the deck they made that is left.
+ * logged in, which lists the deck they made that is left. Writing,
+ * checking, trying and publishing a course is studio-course.journey.ts's.
*/
test("open the Studio from Solid Memo and manage an instance's decks @studio", async ({
app,
account,
runId,
- playwright,
}) => {
const instance = `Studio ${runId}`;
const alpha = `Alpha ${runId}`;
@@ -94,7 +69,6 @@ test("open the Studio from Solid Memo and manage an instance's decks @studio", a
const group = `Pair ${runId}`;
const renamed = `Renamed ${runId}`;
const course = `Course ${runId}`;
- const authored = `Authored ${runId}`;
await app.step("01 · Visit the Studio's old address: the Studio's landing page", () =>
app.studio.visitOldAddress(),
@@ -315,108 +289,25 @@ test("open the Studio from Solid Memo and manage an instance's decks @studio", a
},
);
- await app.step(
- "25 · Write a course: a chapter, a step, and two questions with two wrong options each",
- async () => {
- await app.studio.openDrafts(instance);
- await app.studio.draftCourse(instance, authored);
- await app.draftEditor.openDraft(authored);
- await app.draftEditor.addChapter("Pods");
- await app.draftEditor.openChapter("Pods");
- await app.draftEditor.addStep();
- await app.draftEditor.writeTheory("A pod is where your data lives.");
- await app.draftEditor.addQuestion("Where does your data live?", "In a pod");
- await app.draftEditor.addQuestion("Who chooses the app?", "You do");
- await app.draftEditor.openQuestion("Where does your data live?");
- await app.draftEditor.addWrongOption("In the app", "Apps only read it.");
- await app.draftEditor.addWrongOption("On a server of the app's", "The pod is yours.");
- await app.draftEditor.answerPreviewWrong("In the app", "Apps only read it.");
- await app.draftEditor.expectOutline(authored, "Pods", "Step 1.1", [
- "Where does your data live?",
- "Who chooses the app?",
- ]);
- await app.draftEditor.expectBadge("Who chooses the app?", 1);
- },
- );
-
- await app.step(
- "26 · Check the release: follow its problems to their fields, fix them, and see them clear",
- async () => {
- const fewOptions = app.t("studio.problem.fewDistractors", { count: 0, least: 2 });
- const noDescription = app.t("studio.problem.required", {
- field: app.t("studio.field.description"),
- });
- const release = app.t("studio.check.release");
- await app.draftEditor.openCheck();
- await app.draftEditor.expectProblem("Who chooses the app?", fewOptions);
- await app.draftEditor.expectProblem(release, noDescription);
- await app.draftEditor.followProblem("Who chooses the app?", fewOptions);
- await app.draftEditor.addWrongOption("The pod", "A pod holds data.");
- await app.draftEditor.addWrongOption("Its maker", "The user chooses.");
- await app.draftEditor.openCheck();
- await app.draftEditor.expectNoProblem("Who chooses the app?", fewOptions);
- await app.draftEditor.followProblem(release, noDescription);
- await app.draftEditor.writeDescription("What a pod is, and who holds the data in it.");
- await app.draftEditor.openCheck();
- await app.draftEditor.expectNoProblem(release, noDescription);
- await app.draftEditor.checkShapes();
- await app.draftEditor.openPreview(authored);
- await app.chrome.breadcrumb("breadcrumbs.decks");
- },
- );
-
- await app.step("27 · Rename the instance and describe its catalog", async () => {
+ await app.step("25 · Rename the instance and describe its catalog", async () => {
await app.studio.openInstance(instance);
await app.studio.renameInstance(renamed);
await app.studio.describeCatalog("Decks for the Studio journey.", "CC0 1.0");
});
- await app.step("28 · Pick the instance, by its new name, from the instance picker", async () => {
+ await app.step("26 · Pick the instance, by its new name, from the instance picker", async () => {
await app.studio.pickInstance(renamed);
await app.studio.expectDeck(renamed, alpha, { cards: 1, due: 0 });
});
- await app.step("29 · See the library deck among the library copies, up to date", async () => {
+ await app.step("27 · See the library deck among the library copies, up to date", async () => {
await app.studio.openLibraryCopies(renamed, BRIGHTEST_STARS.en);
await app.chrome.breadcrumb("breadcrumbs.decks");
});
- const counted: Record = {};
- const tiles = ["answers", "studyDays", "cards"] as const;
- await app.step("30 · Note what Solid Memo's statistics count", async () => {
- await app.studio.backToApp();
- await app.statistics.open();
- for (const tile of tiles) counted[tile] = await app.statistics.value(tile);
- });
-
- await app.step(
- "31 · Test-play the course in the Studio's trial, to the end of its final review",
- async () => {
- await app.studio.openFromApp();
- await app.studio.openDrafts(renamed);
- await app.draftEditor.openDraft(authored);
- await app.trial.open(authored);
- await app.trial.playToTheEnd(COURSE.chapter, COURSE.theory, COURSE.answers);
- },
- );
-
- await app.step(
- "32 · The instance's decks and Solid Memo's statistics are as they were",
- async () => {
- await app.chrome.breadcrumb("breadcrumbs.decks");
- await app.studio.expectDeck(renamed, alpha, { cards: 1, due: 0 });
- await app.studio.expectDeck(renamed, beta, { cards: 1, due: 1 });
- await app.studio.expectNoDeck(renamed, authored);
- await app.studio.backToApp();
- await app.statistics.open();
- for (const tile of tiles) expect(await app.statistics.value(tile), tile).toBe(counted[tile]);
- },
- );
-
await app.step(
- "33 · Draft the library deck's next version, retire a card, and compare it with the release: learners lose nothing",
+ "28 · Draft the library deck's next version, retire a card, and compare it with the release: learners lose nothing",
async () => {
- await app.studio.openFromApp();
await app.studio.openDrafts(renamed);
// A library release by its address, which the site serves (docs/deck-library.md).
const release = "https://solid-memo.com/decks/brightest-stars/v1.ttl";
@@ -435,50 +326,7 @@ test("open the Studio from Solid Memo and manage an instance's decks @studio", a
);
await app.step(
- "34 · Say how the course was made: its author, an attribution, its license, a source and an AI check; follow the source's warning and fix it",
- async () => {
- const source = "https://example.org/pods-explained";
- const undescribed = app.t("studio.problem.sourceUndescribed", {
- missing: app.t("studio.release.detail.creator"),
- });
- await app.chrome.breadcrumb("breadcrumbs.decks");
- await app.studio.openDrafts(renamed);
- await app.draftEditor.openDraft(authored);
- await app.draftEditor.openRelease();
- await app.draftEditor.addAuthor("Journey Author");
- await app.draftEditor.attributeWithAi("Journey Author");
- await app.draftEditor.chooseReleaseLicense("CC0 1.0");
- await app.draftEditor.addSource(source, "Pods explained", "The page's footer: CC0.");
- await app.draftEditor.recordAiCheck("Facts", "every question", "none wrong");
- await app.draftEditor.openCheck();
- await app.draftEditor.expectProblem(source, undescribed);
- await app.draftEditor.followProblem(source, undescribed);
- await app.draftEditor.describeSource(source, "Pods explained", "Example authors");
- await app.draftEditor.openCheck();
- await app.draftEditor.expectNoProblem(source, undescribed);
- },
- );
-
- await app.step(
- "35 · Publish the course to the pod, public: anyone can read it, and the releases list it",
- async () => {
- await app.draftEditor.openRelease();
- await app.draftEditor.choosePublisher("Journey Author");
- const url = await app.draftEditor.publish();
- // Someone with no login reads it.
- const anyone = await playwright.request.newContext();
- const read = await anyone.get(url, { headers: { accept: "text/turtle" } });
- expect(read.status()).toBe(200);
- expect(await read.text()).toContain(`"${authored}"@en`);
- await anyone.dispose();
- await app.studio.openReleases(renamed);
- await app.studio.expectRelease(renamed, url, `authored-${runId}`, 1);
- published = { url, title: authored };
- },
- );
-
- await app.step(
- "36 · Move down the library deck's cards with the keyboard and back, one selected: each row in sight between the header row and the bar",
+ "29 · Move down the library deck's cards with the keyboard and back, one selected: each row in sight between the header row and the bar",
async () => {
await app.chrome.breadcrumb("breadcrumbs.decks");
await app.studio.moveThroughCardsInSight(renamed, BRIGHTEST_STARS.en, 30);
@@ -486,7 +334,7 @@ test("open the Studio from Solid Memo and manage an instance's decks @studio", a
);
await app.step(
- "37 · Draw the instance as a graph: open a node by keyboard, find the library release the next version follows, a leaf never fetched, see it and its draft in the table, and let go of it",
+ "30 · Draw the instance as a graph: open a node by keyboard, find the library release the next version follows, a leaf never fetched, see it and its draft in the table, and let go of it",
async () => {
await app.chrome.breadcrumb("breadcrumbs.decks");
await app.studio.openGraph(renamed);
@@ -503,52 +351,9 @@ test("open the Studio from Solid Memo and manage an instance's decks @studio", a
},
);
- await app.step("38 · Go back to Solid Memo, still logged in", async () => {
+ await app.step("31 · Go back to Solid Memo, still logged in", async () => {
await app.studio.backToApp();
await app.chrome.expectLoggedInAs(account.webId);
await app.decks.expectDeck(alpha);
});
});
-
-/**
- * A course published in a pod, added from its link by someone else
- * (docs/deck-library.md, From a link): another user, with an account and
- * a pod of their own, opens "Add from a link" from their empty deck list
- * and pastes the address of the course the Studio journey published.
- * The release is read and shown, with the host it is published on, as a
- * course. They start it, and play its chapter to the end of its final
- * review, as a library course is played. Their deck list then says where
- * the course came from.
- */
-test("add a course published in a pod from its link, as another user, and play its chapter @studio", async ({
- app,
- account,
- runId,
-}) => {
- const course = published;
- expect(course, "the Studio journey, just before, publishes the course").not.toBeNull();
- const { url, title } = course!;
-
- await app.step("01 · Visit Solid Memo", () => app.onboarding.visit());
- await app.step("02 · Log in as another user and create an instance", () =>
- logInAndCreateInstance(app, account, `Learner ${runId}`),
- );
- await app.step(
- "03 · Paste the course's link: what it is, and where it is published",
- async () => {
- await app.importUrl.openFromDeckList();
- await app.importUrl.showCourse(url, title);
- },
- );
- await app.step(
- "04 · Start the course and play its chapter to the end of its final review",
- async () => {
- await app.importUrl.startCourse(title);
- await app.course.playChapter(COURSE.chapter, COURSE.theory, COURSE.answers);
- },
- );
- await app.step("05 · Back at Decks: the course says where it came from", async () => {
- await app.chrome.breadcrumb("breadcrumbs.decks");
- await app.decks.expectDeckFrom(title, new URL(url).host);
- });
-});
diff --git a/e2e/journeys/journeys/study-reload.journey.ts b/e2e/journeys/journeys/study-reload.journey.ts
index f8efc103..98af0b5d 100644
--- a/e2e/journeys/journeys/study-reload.journey.ts
+++ b/e2e/journeys/journeys/study-reload.journey.ts
@@ -12,7 +12,9 @@ import { logInAndCreateInstance } from "../flows/logIn.ts";
* deck list, the statistics and each card's history count every answer
* once (docs/srs.md#study-sessions). Catches answers kept only in the
* page, a session that starts over with the cards already answered, and
- * a reload that loses the route.
+ * a reload that loses the route. The statistics and each card's history
+ * are checked in Chromium. Then the learner logs out: in the other
+ * engines, which run no smoke journey, this is the one that does.
*
* Four new cards, all within the five new cards a day: two answered
* Good before the reload, the other two after it. Good keeps a card out
@@ -89,28 +91,43 @@ test("reload in the middle of a study session @reload", async ({ app, account, r
await app.decks.expectAnsweredToday(cards);
});
- await app.step("09 · The statistics count each answer once", async () => {
- await app.statistics.open();
- await app.statistics.expectStatistics({
- answers: cards,
- studyDays: 1,
- cards,
- // Four new cards, each answered once, Good: no review, no repeat.
- today: { answers: cards, introduced: cards, remembered: 0, lapses: 0, repeats: 0 },
- decks: [{ name: deck, answers: cards, introduced: cards, lapses: 0, lastStudied: "today" }],
- maturity: { new: 0, relearning: 0, young: cards, mature: 0 },
- });
- });
+ // The statistics and the history are the app's logic: Chromium checks them.
+ await app.step(
+ "09 · The statistics count each answer once",
+ async () => {
+ await app.statistics.open();
+ await app.statistics.expectStatistics({
+ answers: cards,
+ studyDays: 1,
+ cards,
+ // Four new cards, each answered once, Good: no review, no repeat.
+ today: { answers: cards, introduced: cards, remembered: 0, lapses: 0, repeats: 0 },
+ decks: [{ name: deck, answers: cards, introduced: cards, lapses: 0, lastStudied: "today" }],
+ maturity: { new: 0, relearning: 0, young: cards, mature: 0 },
+ });
+ },
+ { in: ["chromium"] },
+ );
+
+ await app.step(
+ "10 · Each card's history holds one answer",
+ async () => {
+ for (const front of fronts) {
+ await app.chrome.breadcrumb("breadcrumbs.decks");
+ await app.deckBrowser.openFor(deck);
+ await app.cardEditor.open(front);
+ // The history names an answer by the four buttons' words, whichever scale gave it.
+ expect(await app.cardEditor.history(), front).toEqual([
+ { kind: app.t("card.historyFirst"), answer: app.t("study.minimal.good") },
+ ]);
+ }
+ },
+ { in: ["chromium"] },
+ );
- await app.step("10 · Each card's history holds one answer", async () => {
- for (const front of fronts) {
- await app.chrome.breadcrumb("breadcrumbs.decks");
- await app.deckBrowser.openFor(deck);
- await app.cardEditor.open(front);
- // The history names an answer by the four buttons' words, whichever scale gave it.
- expect(await app.cardEditor.history(), front).toEqual([
- { kind: app.t("card.historyFirst"), answer: app.t("study.minimal.good") },
- ]);
- }
+ // The other engines run no smoke journey: this one logs out for it.
+ await app.step("11 · Log out", async () => {
+ await app.chrome.logOut();
+ await app.onboarding.expectLoggedOut();
});
});
diff --git a/e2e/journeys/journeys/try-link.journey.ts b/e2e/journeys/journeys/try-link.journey.ts
index 51c72dce..2bf6e61b 100644
--- a/e2e/journeys/journeys/try-link.journey.ts
+++ b/e2e/journeys/journeys/try-link.journey.ts
@@ -17,7 +17,8 @@ import { BRIGHTEST_STARS } from "../pages/Library.ts";
* no one else can open (a guest's exists only in their browser); the
* landing page shown instead of a guest's study started; and the link
* left in the history, or not followed on to the deck's page once the
- * guest's instance is made.
+ * guest's instance is made. The other engines follow the link; importing,
+ * studying and the unknown link are checked in Chromium.
*/
test("a library deck shared by its link @guest", async ({ app, newDevice }) => {
const stars = BRIGHTEST_STARS.en;
@@ -41,19 +42,29 @@ test("a library deck shared by its link @guest", async ({ app, newDevice }) => {
friend.libraryDeck.followLink(link, stars),
);
- await friend.step("04 · Import it and study a card", async () => {
- await friend.libraryDeck.import(stars);
- await friend.decks.study(stars);
- await friend.study.expectStudying(stars);
- await friend.study.answer("good");
- await friend.study.endSession();
- await friend.decks.expectAnsweredToday(1);
- });
+ // Importing and studying are the app's logic, which the guest journey
+ // goes through in every engine: Chromium checks them here.
+ await friend.step(
+ "04 · Import it and study a card",
+ async () => {
+ await friend.libraryDeck.import(stars);
+ await friend.decks.study(stars);
+ await friend.study.expectStudying(stars);
+ await friend.study.answer("good");
+ await friend.study.endSession();
+ await friend.decks.expectAnsweredToday(1);
+ },
+ { in: ["chromium"] },
+ );
- await friend.step("05 · Follow a link to a deck the library does not have", async () => {
- const url = new URL(link);
- url.hash = `#/try?deck=${encodeURIComponent(new URL("decks/index.ttl#no-such-deck", url).href)}`;
- await friend.page.goto(url.href);
- await friend.library.expectFromUnknownLink();
- });
+ await friend.step(
+ "05 · Follow a link to a deck the library does not have",
+ async () => {
+ const url = new URL(link);
+ url.hash = `#/try?deck=${encodeURIComponent(new URL("decks/index.ttl#no-such-deck", url).href)}`;
+ await friend.page.goto(url.href);
+ await friend.library.expectFromUnknownLink();
+ },
+ { in: ["chromium"] },
+ );
});
diff --git a/e2e/journeys/pages/App.ts b/e2e/journeys/pages/App.ts
index f145867b..4ccb2c9f 100644
--- a/e2e/journeys/pages/App.ts
+++ b/e2e/journeys/pages/App.ts
@@ -133,9 +133,26 @@ export class App {
* One step of a journey, as the journey's spec names it ("03 · Set new
* cards per day to 1"): a group in the report and the trace, with a
* screenshot of where it left the app, and that screen checked for
- * accessibility.
+ * accessibility, in a step of its own, "Accessibility", so the report
+ * says how long the check took apart from the step's own work. A
+ * project whose metadata says `accessibility: false` leaves the check
+ * out (playwright.config.ts).
+ *
+ * `in` names the projects the step runs in; elsewhere it is skipped,
+ * and the report says so. It is for a step that tests the app's logic,
+ * the same in every engine, in a journey the other engines run for
+ * what differs between them (docs/testing.md#browsers).
*/
- async step(title: string, body: () => Promise): Promise {
+ async step(
+ title: string,
+ body: () => Promise,
+ { in: projects }: { in?: readonly string[] } = {},
+ ): Promise {
+ if (projects !== undefined && !projects.includes(this.testInfo.project.name)) {
+ await test.step
+ .skip(title, async () => {});
+ return;
+ }
await test.step(title, async () => {
await body();
const unmet = this.expectedDialogs.splice(0).map(String);
@@ -144,7 +161,9 @@ export class App {
body: await this.page.screenshot(),
contentType: "image/png",
});
- await this.checkAccessibility(title);
+ if (this.testInfo.project.metadata.accessibility !== false) {
+ await test.step("Accessibility", () => this.checkAccessibility(title));
+ }
});
}
diff --git a/e2e/journeys/playwright.config.ts b/e2e/journeys/playwright.config.ts
index 43df2645..348c91f3 100644
--- a/e2e/journeys/playwright.config.ts
+++ b/e2e/journeys/playwright.config.ts
@@ -20,12 +20,13 @@ if (!(TRACE_MODES as readonly string[]).includes(trace))
throw new Error(`JOURNEY_TRACE is "${trace}"; it may be ${TRACE_MODES.join(", ")}.`);
/**
- * The journeys the other engines and the phone run: logging in, a guest's
- * study kept in the browser through the login's redirects, and the silent
- * restore of a session after a reload, which differ most between engines.
- * The rest exercise the app's logic, which doesn't (docs/testing.md#browsers).
+ * The journeys the other engines and the phone run: a guest's study kept
+ * in the browser through the login's redirects, and the silent restore of
+ * a session after a reload, which differ most between engines; the
+ * reload's journey also logs in and out, as @smoke does in Chromium. The
+ * rest exercise the app's logic, which doesn't (docs/testing.md#browsers).
*/
-const ENGINE_JOURNEYS = /@smoke|@guest|@reload/;
+const ENGINE_JOURNEYS = /@guest|@reload/;
export default defineConfig({
testDir: "journeys",
@@ -64,10 +65,28 @@ export default defineConfig({
video: "retain-on-failure",
screenshot: "only-on-failure",
},
+ // Axe checks each step's screen in Chromium and on the phone, which is
+ // WebKit too; desktop Firefox and WebKit leave it out
+ // (docs/accessibility.md): it takes about 40% of a journey's time, and
+ // the same screens are checked in Chromium.
projects: [
- { name: "chromium", use: { ...devices["Desktop Chrome"] } },
- { name: "firefox", use: { ...devices["Desktop Firefox"] }, grep: ENGINE_JOURNEYS },
- { name: "webkit", use: { ...devices["Desktop Safari"] }, grep: ENGINE_JOURNEYS },
+ // Chromium runs every journey, in two shards in CI: a file's journeys
+ // too may run side by side, and in either shard, so pod-safety's six
+ // do not take one shard's last 100 seconds. A file whose journeys
+ // depend on each other says so (studio-course.journey.ts, serial).
+ { name: "chromium", use: { ...devices["Desktop Chrome"] }, fullyParallel: true },
+ {
+ name: "firefox",
+ use: { ...devices["Desktop Firefox"] },
+ grep: ENGINE_JOURNEYS,
+ metadata: { accessibility: false },
+ },
+ {
+ name: "webkit",
+ use: { ...devices["Desktop Safari"] },
+ grep: ENGINE_JOURNEYS,
+ metadata: { accessibility: false },
+ },
// Safari on an iPhone: a phone's viewport, touch, and WebKit.
{ name: "phone", use: { ...devices["iPhone 14"] }, grep: ENGINE_JOURNEYS },
],