diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe5b8f2..bd28061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,21 @@ jobs: - run: pnpm install --frozen-lockfile + # The other two jobs set ELECTRON_SKIP_BINARY_DOWNLOAD, and all three + # share one pnpm store cache keyed on the lockfile, so whichever of them + # saves that cache first can hand this job an electron package whose + # postinstall was skipped and whose binary is therefore missing. The + # symptom is "Electron failed to install correctly" out of + # electron.launch, and because it depends on which parallel job wins the + # cache write, it appears as flake rather than as a broken job. + # + # install.js is the package's own postinstall. It checks the installed + # version first and downloads only what is missing, reusing the binary + # cache restored above, so running it here is idempotent and costs + # nothing when the store cache was clean. + - name: Repair the Electron binary if the shared store cache skipped it + run: node node_modules/electron/install.js + - run: pnpm e2e --project=electron - uses: actions/upload-artifact@v4 diff --git a/e2e/journeys/create-book.spec.ts b/e2e/journeys/create-book.spec.ts index 608959c..51734e3 100644 --- a/e2e/journeys/create-book.spec.ts +++ b/e2e/journeys/create-book.spec.ts @@ -97,16 +97,28 @@ test('revises the table of contents, persists it, and generates chapter 1 on app // Persistence, checked on disk rather than on screen: the revision wrote // through to toc.yml, not just into the streaming buffer the page renders. - const toc = await bookRepository(app.dataDir).getToc(id) - expect(toc.chapters.map(chapter => chapter.title)).toEqual(TOC_REVISED_CHAPTERS.map(chapter => chapter.title)) + // + // Polled rather than read once, because the two are not simultaneous. + // Titles appear on screen while `revise-toc.ts` is still streaming, and the + // parse and the save both happen after the stream ends, so a bare read here + // races a write that is strictly later. Polling waits for that write on a + // deadline, the same way a web-first assertion waits for the screen, and it + // weakens nothing because the expected value is unchanged. + await expect.poll(async () => (await bookRepository(app.dataDir).getToc(id)).chapters.map(chapter => chapter.title)) + .toEqual(TOC_REVISED_CHAPTERS.map(chapter => chapter.title)) await wizard(page).approveToc() await expect(page.getByText(chapterMarker(1)).first()).toBeVisible() - const book = await readBook(app.dataDir, id) - expect(book.generatedUpTo).toBe(1) - expect(book.status).toBe('reading') + // Same reasoning, and this is the one that actually caught it. `start-book.ts` + // streams the chapter, saves it, generates its quiz, and only then writes + // generatedUpTo and status in its finalize step. Chapter 1's prose is on + // screen well before that, so reading the book once at this point asks a + // question the server has not answered yet. It won on every local run and on + // the pull request, and lost on the busier post-merge runner. + await expect.poll(async () => (await readBook(app.dataDir, id)).generatedUpTo).toBe(1) + expect((await readBook(app.dataDir, id)).status).toBe('reading') // And the model path /start actually drives: a skill classification call // and a chapter-1 stream, not just the revise-toc stream from earlier.