Skip to content

ci(editorjs): run the VoiceOver suite on the merge queue - #190

Merged
gohabereg merged 29 commits into
mainfrom
ci/voiceover-gate
Oct 9, 2026
Merged

gohabereg merged 29 commits into
mainfrom
ci/voiceover-gate

Conversation

@gohabereg

@gohabereg gohabereg commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Stacked on #187. Runs the 18-case VoiceOver suite in CI, on the merge queue only.

Why the merge queue and not every PR

The suite drives a real VoiceOver instance one test at a time and takes ~8 minutes, which is too
slow to sit in front of every push. It is wired as a job condition, not a paths: filter on
the workflow, and the distinction matters:

  • A workflow skipped by a paths: filter leaves its checks Pending, which blocks a PR
    required to pass it. GitHub's own advice is "avoid requiring workflows that can be skipped".
  • A job skipped by if: reports success and does not block.

So editorjs.yml still runs on pull_request for the rest of its jobs, and only the macOS job
carries if: github.event_name == 'merge_group'.

An earlier revision of this PR tried to narrow it with a change-detection script instead. That is
removed: deciding what counts as "announcement-relevant" turned out to be guesswork that had
already missed the inline tools, and it is moot once the suite is off the PR path.

Getting it green

Three rounds, each a real failure rather than a flake:

  1. npx @guidepup/setup needs a subcommand. Without one it prints usage and exits 1, so every
    later step was skipped. It is setup --ci — the flag keeps it from waiting on prompts for the
    manual steps a local machine would need.
  2. setup and install are both required. setup configures the OS; install puts the
    preferences disk image into the project. With only the first, all 17 cases failed in
    VoiceOver.start() with "Failed to mount Guidepup preferences".
  3. Case 18 never reached a menu item. Its sweep started from the web content root, and once
    the toolbox menu is open VoiceOver will not enter the block actions toolbar from there — a
    forward walk reports "block actions toolbar" at all 25 stops, and even the button that opened
    the menu becomes unreachable. Both sweeps now anchor on that button, which is the position
    Case 4 reaches the items from.

Diagnosing (3) needed two throwaway diagnostics that are kept, because both gaps would recur:
collectReachable returns the whole sweep alongside the matches, and walkTo's error carries the
route it took. The first failure said only Expected: > 0, Received: 0; the second said only
"did not reach it" — neither distinguished "swept past" from "stalled against a wall".

retries: isCI ? 2 : 0 matches playwright.config.ts. It earned itself immediately: on one run
Case 7 failed and passed on retry (a genuine timing flake) while Case 18 failed all three, which
is what separated the two.

Verification

Suite green locally: 18/18, ~6–8 minutes.

CI reached the same point — 16 passed, 1 flaky, 1 failed — before the Case 18 fix, which was
diagnosed from the CI announcement logs and confirmed locally.

Before this gates anything

Two repository settings, neither of which I can make:

  1. Enable the merge queue on main. required_merge_queue is null today, so merge_group
    never fires and this job is inert until it is turned on.
  2. Then add VoiceOver to the required checks. Running in the queue does not gate on its own.

Worth considering first: package-check / e2e-tests is also not required. The headless
aria.spec.ts / axe.spec.ts suite runs on every PR, takes seconds, is deterministic, and covers
the structural half of the same surface — the cheaper gate to require.

🤖 Generated with Claude Code

gohabereg and others added 17 commits July 22, 2026 22:28
…eadless

Introduce `@editorjs/editorjs`, a batteries-included entry point that composes
`@editorjs/core` with the default DOM adapter, collaboration, tools, plugins,
and UI, auto-initializes in its constructor, and exposes an `isReady` promise.
It honors a v2-style `config.tools` map merged over the defaults with
override-by-name.

To support it, `Core` becomes a headless engine:
- Remove the hardcoded default tool/plugin/infra `use()` calls from the
  constructor; drop the corresponding dependencies.
- `initialize()` now fails loudly: it throws when no rendering adapter is
  registered or the `defaultBlock` tool is missing, and re-throws init
  failures instead of swallowing them.

`ShortcutsPlugin` stays in `@editorjs/core` (no longer auto-registered); its
relocation is handled by a separate change.

Free the `@editorjs/editorjs` package name by aliasing the published v2
dependency as `editorjs-v2` (`npm:@editorjs/editorjs@…`) across core, sdk, ui,
and tools/paragraph.

The playground now consumes `@editorjs/editorjs` instead of wiring `Core` and
UI by hand.

Specs updated via OpenSpec change `add-editorjs-bundle-package` (archived).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…gration test

Core no longer depends on `@editorjs/dom-adapters`, so its dist is not built
in CI when checking `@editorjs/core`. The leftover `jest.unstable_mockModule`
call still resolved the specifier, failing the suite (and the Stryker dry run)
with "Cannot find module '@editorjs/dom-adapters'".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Runs lint, build and unit tests for the new bundle package on pull requests
and merge groups, mirroring the other package workflows. Mutation tests are
disabled since the package has no Stryker config yet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
`tsc --build` only produces output for TypeScript project references, so
`@editorjs/ui` — which is bundled by vite — had no `dist/ui.js` after building
`@editorjs/editorjs` from a clean checkout. Jest then failed to resolve
`@editorjs/ui` in the bundle test.

Build dependencies first via `yarn workspaces foreach`, mirroring how the
playground package builds its own dependency graph.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Core.initialize() no longer logs to console.error before re-throwing
  (dead try/catch removed)
- BlockRenderer/BlockManager import editorjs-v2 types with `import type`
  instead of pulling it in as a runtime dependency
- Playground attaches a .catch() to editor.isReady instead of discarding
  the promise, avoiding an unhandled rejection
- mergeTools() now throws when a config.tools key doesn't match the
  tool's static name, since Core looks up tools by name, not map key
- editorjs-bundle spec: replaced TBD Purpose and documented the new
  fail-loud mismatch scenario

Co-Authored-By: Claude Sonnet 5 <[email protected]>
…hange

Adds a Playwright e2e setup for @editorjs/editorjs (Vite-served fixture
mounting the real bundle from src/, no build step) covering paragraph
rendering, typing, and inline-toolbar bold. Documents a gotcha where
locator.press()/selectText() desync Editor.js's native caret tracking.

Writing these tests surfaced that @editorjs/ui and the paragraph tool
emit no ARIA roles/names anywhere, forcing CSS-class selectors instead
of getByRole/getByLabel. Proposes openspec change
add-editor-aria-semantics to close that gap.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
…ebkit e2e target

Replace role="textbox" on the blocks holder with role="group" to avoid
nesting a textbox inside another textbox — contenteditable elements
implicitly carry role="textbox" via HTML-AAM regardless of explicit role,
so putting it on the holder too would be an ARIA anti-pattern.

Instead, assign role="textbox" + aria-multiline="true" + aria-label to
each block's own contenteditable element (starting with Paragraph). Update
proposal, design decisions, specs (ui + tools), and task checklist to
reflect the revised approach.

Also adds webkit (Desktop Safari) as a second Playwright project alongside
chromium in packages/editorjs/playwright.config.ts.
The editor emitted zero roles or accessible names, which made it unusable
with a screen reader and forced the e2e suite onto CSS-class selectors.

- BlocksUI: role="group" on the holder, so the block textboxes are not
  nested inside a textbox the holder would otherwise imply
- Paragraph: role="textbox", aria-multiline, aria-label, and the configured
  placeholder as aria-placeholder
- ToolbarUI: role="toolbar" with a roving tabindex, named plus button,
  aria-haspopup/aria-expanded, and focus moved to the button on open so
  Safari restores it there rather than to <body>
- InlineToolbarUI: named toolbar, aria-activedescendant mirrored from
  ui-kit's highlight and made resolvable with aria-owns, plus a live region
  announcing the toolbar once per open
- Popover item roles and names come from @editorjs/ui-kit 2.0.0, fixed
  upstream rather than worked around from here

Covered by aria.spec.ts, axe.spec.ts and a VoiceOver suite driving a real
screen reader (voiceover.spec.ts, macOS-only, excluded from test:e2e).

Note: @editorjs/ui-kit 2.0.0 is not published yet, so the lockfile has no
entry for it and `yarn install` fails. The local `link:` resolution the work
was developed against has been removed deliberately — see tasks.md 5a.2/5a.3.

Co-Authored-By: Claude Opus 5 <[email protected]>
The ARIA semantics the UI now depends on are enforced only by Playwright —
nothing in the type surface catches a dropped role or accessible name, so
until now a regression could land unnoticed.

Adds an `include-e2e` input to the reusable package-check workflow and an
`e2e-tests` composite action that installs chromium and webkit (cached by
lockfile hash) and runs `test:e2e`. Kept as its own job: it needs browsers
and takes minutes, and its failures read differently from a unit test's.
`voiceover.spec.ts` stays excluded — screen readers cannot run headless.

Co-Authored-By: Claude Opus 5 <[email protected]>
main landed the squashed bundle package (#183) plus work this branch predates,
so the overlap had to be reconciled by hand:

- ShortcutsPlugin: kept main's extraction into @editorjs/shortcuts-plugin — the
  bundle depends on it, registers it as a default plugin, and core no longer
  ships it. Added to editorjs's package.json, tsconfigs and index.ts alongside
  ClipboardPlugin.
- ToolConfig now comes from @editorjs/sdk (#188) instead of editorjs-v2; dropped
  the alias import in sdk entities and paragraph while keeping this branch's
  ARIA changes in paragraph.
- Core.initialize resolves UndoRedoManager after plugins/tools (main's ordering)
  and registers PluginRegistry.
- packages/ui: took main's chokidar dev script and dependency reshuffle, kept
  this branch's @editorjs/ui-kit ^2.0.0 pin.
- openspec: took main's archived bundle change and updated core/editorjs-bundle
  specs; this branch's add-editor-aria-semantics change is untouched.
- Playwright/e2e tooling, .gitignore, eslint config and the e2e CI job are kept
  from this branch.

yarn.lock was regenerated from main's and still has no @editorjs/ui-kit entry:
2.0.0 is not published yet, so `yarn install` fails exactly as it did on this
branch before the merge. Verified with the workspace's tsc/jest directly —
core (179), editorjs (8) pass; core, paragraph, shortcuts-plugin and editorjs
typecheck; packages/ui's three errors are the unpublished ui-kit 2.0.0 APIs.

Co-Authored-By: Claude Opus 5 <[email protected]>
…cape

Escape closes the popover inside ui-kit, without going through #hide(),
so #isVisible stayed true and every later appearance was taken for a
reselection while already open: the toolbar was never announced again.

Listen for PopoverEvent.Closed to reset it. The listener is removed
before the popover is destroyed, since destroy() emits Closed as well
and a rebuild would otherwise re-announce the toolbar on every
selection change.

Co-Authored-By: Claude Opus 5 <[email protected]>
With only the list reporter nothing was written to playwright-report/,
so the upload step had nothing to upload and the traces recorded on a
retry were lost with the runner.

Co-Authored-By: Claude Opus 5 <[email protected]>
Hashing yarn.lock dropped the ~400MB browser cache on any unrelated
dependency bump. Each Playwright release pins its own browser builds,
so the installed version is all the key needs.

Also bump upload-artifact to v7, in line with the other actions.

Co-Authored-By: Claude Opus 5 <[email protected]>
@editorjs/ui-kit 2.0.0 depends on @editorjs/dom ^1.2.0, while the workspace
packages still asked for ^1.1.0. Yarn will not re-resolve an already-locked
entry, so the two ranges resolved separately and node_modules ended up with
two copies of @editorjs/dom - including a nested one under @editorjs/caret,
which pins ^1.1.0 transitively. Two copies split module state and break any
identity check across the boundary.

Widen the workspace ranges to ^1.2.0 and run `yarn dedupe @editorjs/dom` so
both ranges collapse onto a single 1.2.0 resolution. The lockfile also gains
the @editorjs/ui-kit 2.0.0 resolution it was missing.

Co-Authored-By: Claude Opus 5 <[email protected]>
`#popoverElement` was only ever written: assigned from `getElement()`, used as
the receiver for the id and the append, then nulled in the teardown. A local
covers all three, and dropping the field removes a second piece of state that
had to be kept in step with `#popover` for no one's benefit.

`editor.spec.ts` was the only spec reimplementing the mount-and-wait sequence
and the triple-click selection instead of importing `mountDocument` and
`selectParagraphText`. The readiness signal it waited on lives in the fixture,
so a change there would have fixed the three specs using the helpers and left
this one timing out.

Co-Authored-By: Claude Opus 5 <[email protected]>
…change

The suite drives a real VoiceOver instance one test at a time, so it cannot sit
in the path of every PR the way the headless suites do. It is wired as a job
condition rather than a `paths:` filter on the workflow: GitHub leaves the
checks of a path-filtered workflow Pending forever, which blocks any PR
required to pass it, whereas a skipped job reports success and does not. The
workflow therefore always runs and the macOS job decides for itself.

`should-run-voiceover.sh` answers a narrower question than `should-run-ci.sh`:
not "could this affect @editorjs/editorjs" - true of almost every change here -
but "could this alter what a screen reader announces". It watches the UI and
paragraph sources, the bundle, the suite itself, the lockfile (@editorjs/ui-kit
owns the popover roles), and its own CI wiring. It fails open on anything it
cannot determine, for the same reason its sibling does.

Runs on a standard macos-15 runner, which is free on a public repository, and
cancels a superseded run so a stale push does not hold one of the five macOS
job slots shared across the account.

Co-Authored-By: Claude Opus 5 <[email protected]>
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Unit Tests

Package Coverage Delta
@editorjs/model 98.48% 0% ⚪️
@editorjs/ot-server 20% 0% ⚪️
@editorjs/editorjs 100% 0% ⚪️
@editorjs/core 73.14% 0% ⚪️
@editorjs/clipboard-plugin 66.66% 0% ⚪️
@editorjs/dom-adapters 86.95% 0% ⚪️
@editorjs/shortcuts-plugin 100% 0% ⚪️
@editorjs/collaboration-manager 85.81% 0% ⚪️
@editorjs/model-types 59.82% 0% ⚪️

Mutation Tests

Package Mutation score Dashboard URL
@editorjs/core No files to mutate found.
@editorjs/clipboard-plugin No files to mutate found.
@editorjs/model No files to mutate found.
@editorjs/dom-adapters No files to mutate found.
@editorjs/shortcuts-plugin No files to mutate found.
@editorjs/collaboration-manager No files to mutate found.

gohabereg and others added 9 commits September 29, 2026 19:08
`npx @guidepup/setup` runs the package's `guidepup` binary, which needs a
subcommand - without one it prints its usage and exits 1, which is what failed
the job. The correct call is `setup`, plus the `--ci` flag the README calls for
in CI so the command does not wait on the manual steps a local machine needs.

The change detection goes away with it. Deciding what counts as announcement-
relevant turned out to be guesswork that had already missed the inline tools,
and it is moot once the suite moves off the PR path onto a schedule.

Runs on every PR for now, only so the job can be proven green before that move.

Co-Authored-By: Claude Opus 5 <[email protected]>
`setup` and `install` are separate commands and both are required. `setup`
configures the OS; `install` puts the preferences disk image into the project.
With only the first, every test failed in VoiceOver.start() with "Failed to
mount Guidepup preferences" out of resolveDmgPath - the error names both
commands, and only one of them had been run.

Co-Authored-By: Claude Opus 5 <[email protected]>
The baseline assertion guards the real one, but it fails on an *empty* match
list - and the matches alone say nothing in that case. On CI it failed with
"Expected: > 0, Received: 0" and no indication whether the cursor never reached
the menu or VoiceOver worded the item differently than /menu item/ expects.
`collectReachable` now carries the whole sweep alongside the matches so the
message can distinguish the two.

Retries follow playwright.config.ts, which already uses `isCI ? 2 : 0`. A real
screen reader is driven by real keystrokes whose timing shifts under a loaded
runner, and resetCursor's own comment records Case 18 as having been
non-deterministic before. This does not hide a regression: a case that fails
three times running is not a timing artefact, and the message now says what was
announced each time.

Co-Authored-By: Claude Opus 5 <[email protected]>
The sweep never reached a menu item because it started from the top of the web
content. VO+Right moves between siblings rather than into them, and the block
actions toolbar is the last sibling, so `next()` returned that same item for all
twenty-five stops: ["hello world paragraph...", "block actions toolbar" x24].
The menu was open and correct the whole time - the page snapshot in the failure
artifact shows menuitem "Text" present.

Walking to the button that opens the toolbox first puts the cursor inside that
container, which is where Case 4 reaches menu items from.

Worth noting the baseline assertion earned its keep exactly as its comment said
it would: without it, "no menu item is reachable after filtering" would have
passed because no menu item was reachable before filtering either.

Co-Authored-By: Claude Opus 5 <[email protected]>
…open

Three CI runs agree on the constraint: once the toolbox menu is open, VoiceOver
will not enter the block actions toolbar from the web content root. A forward
walk from there reports "block actions toolbar" at all twenty-five stops and
never descends, and the previous attempt showed even the button that opened the
menu is unreachable that way - "did not reach an item matching /add block/i
within 40 next steps". Case 4 reaches the items only because it never leaves the
container.

So neither sweep resets now. The first runs from where `act()` left the cursor.
The second walks backwards to the search field, which re-anchors without going
to the root and doubles as the navigation command VoiceOver needs before it will
see the `fill()`.

Co-Authored-By: Claude Opus 5 <[email protected]>
…reen

The investigation notes were useful while the cause was unknown and are noise
now that it is settled. What is left is one line per non-obvious decision.

Co-Authored-By: Claude Opus 5 <[email protected]>
Eight minutes driving a real screen reader is too slow to sit in front of every
PR push. The workflow still runs on pull_request for the rest of its jobs; the
VoiceOver job skips there, and a skipped job reports success, so it never
blocks a pull request.

Inert until a merge queue is enabled on main - `merge_group` does not fire
without one.

Co-Authored-By: Claude Opus 5 <[email protected]>
5a.3 was still written as the blocking, not-yet-done publish. @editorjs/ui-kit
2.0.0 is published, the lockfile resolves it, and the suite was re-run against
the published package: build, 973 unit tests, 80 e2e on both engines, lint. The
task now also records the duplicate @editorjs/dom the regeneration surfaced and
the dedupe that resolved it, since @editorjs/caret still pins the old range.

The verification summary listed the same publish as an outstanding blocker and
its verdict turned on it; both now say otherwise.

Case 18's comment credited the CSS class with removing filtered items from the
accessibility tree. ui-kit sets `hidden` alongside the class and that is what
does it - as the spec and aria.spec.ts both already said.

Co-Authored-By: Claude Opus 5 <[email protected]>
10.5b, 10.7b and 10.8 were records of pre-existing bugs the change deliberately
did not fix, not work items. Every archived change here has zero unchecked
tasks, so as encoded they would have blocked archiving and reported 69/72.

The findings themselves are preserved outside the repo. What referenced them by
number - the proposal, the design note, 10.1c, 10.7a and the verification
summary - now describes the bug instead, so nothing points at a task that is
gone.

Co-Authored-By: Claude Opus 5 <[email protected]>
…gate

# Conflicts:
#	packages/editorjs/e2e/tests/voiceover.spec.ts
@gohabereg gohabereg changed the title ci(editorjs): gate PRs on the VoiceOver suite when announcements can change ci(editorjs): run the VoiceOver suite on the merge queue Sep 29, 2026
Base automatically changed from test/editorjs-e2e-and-aria-openspec to main October 8, 2026 23:37
gohabereg and others added 2 commits October 9, 2026 01:01
# Conflicts:
#	packages/editorjs/e2e/tests/voiceover.spec.ts
#	packages/editorjs/playwright.voiceover.config.ts
Main already archives it under archive/2026-10-08-add-editor-aria-semantics; the copies are identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@gohabereg
gohabereg added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 0895ed3 Oct 9, 2026
80 checks passed
@gohabereg
gohabereg deleted the ci/voiceover-gate branch October 9, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants