Skip to content

Test RedHerb's Vue components - #1218

Draft
malberts wants to merge 3 commits into
masterfrom
feature/redherb-vue-component-tests
Draft

Test RedHerb's Vue components#1218
malberts wants to merge 3 commits into
masterfrom
feature/redherb-vue-component-tests

Conversation

@malberts

@malberts malberts commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #799

RedHerb is the live reference for extending NeoWiki, but nothing exercised its frontend, so a change to
NeoWiki's public API could rot the examples unnoticed. Each of its Vue components is now mounted against
the real ext.neowiki API, asserting the behaviour an extension author copies it for. The specs sit with
NeoWiki's own frontend tests, in resources/ext.neowiki/tests/RedHerb/, alongside where the PHP examples
are already covered from tests/phpunit/RedHerb/. The registration
package file is driven through mw.hook( 'neowiki.registration' ) the way NeoWiki drives it in production,
so the specs cover the wiring as well as the components.

Loading ResourceLoader package files in vitest

RedHerb's resources are ResourceLoader package files: CommonJS closures that pull vue, ext.neowiki and
the CodexModule-generated codex.js and icons.json in with require(). Vite only understands ES modules,
so resources/ext.neowiki/tests/RedHerb/resourceLoaderCommonJs.ts rewrites require() and
module.exports for files under RedHerb's resources directory, and resolves the provided modules as
ext.neowiki would. That last part
matters: RedHerb has no node_modules of its own, and resolving from its own directory would either fail
or hand the examples a second copy of vue and Codex.

Keeping the interop in test-only config leaves RedHerb itself untouched, so it stays copyable into a real
extension. The alternative, giving RedHerb its own vitest setup, would have had to reach into
ext.neowiki's TypeScript sources or its build output, which a real extension cannot do either.

Test collaborators

The specs use the real composables, registries and value components rather than mocking them, since the
point is to catch NeoWiki changes reaching the examples. Only the store methods that would hit the network
and the sortable drag mechanism are stubbed.

CI

ci-ts triggered on resources/** only, so editing a RedHerb component ran neither the new tests nor the
existing ones. It now also triggers on tests/RedHerb/**.

🤖 Generated with Claude Code

malberts and others added 3 commits July 30, 2026 10:22
RedHerb is the live reference for extending NeoWiki, but nothing exercised
its frontend, so a change to NeoWiki's public API could rot the examples
unnoticed. Mount each of its Vue components against the real ext.neowiki
API and assert the behaviour an extension author copies them for.

The components are ResourceLoader package files: CommonJS closures that
pull vue, ext.neowiki and the CodexModule-generated codex.js and icons.json
in with require(). A test-only Vite plugin rewrites those two constructs to
ES modules and resolves the provided modules as ext.neowiki would, so the
examples share the vue and Codex instances the rest of the suite uses and
stay copyable into a real extension.

ci-ts also runs on tests/RedHerb changes now, so editing an example runs
the tests covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing a dialog through Escape or its close button goes through a
different handler than its cancel action, and has to clear the shared
state the mount point holds rather than only hide the dialog.

Assert the optional state of the color field through CdxField rather
than the Codex class name that renders it, and stop pinning the whole
style attribute of a swatch when only the color it paints matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Building the transformed single-file component with String.replace gave
$& and its siblings their substitution meaning, so a script containing
one would have been rewritten into something that no longer parses.

Narrow the directory check to the contents of the source directory
rather than any path starting with its name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@malberts

malberts commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Harness approach: where this PR sits, and an axis the issue did not cover

Against the plan in the issue

The issue proposed two harness options: A, a vitest workspace with a second project rooted at the
extension root, and B, extending the existing project. A was preferred, for mirroring that RedHerb is
conceptually a separate extension.

This PR is closest to B, and further from A than B is: the specs live inside resources/ext.neowiki/tests/RedHerb/,
so the existing project discovers them by default, and the aliases and transform live in the existing
vitest.config.ts. It also went past the proposed smoke-test scope (mount without throwing, assert props passed to a
stubbed nw.SubjectEditor, one interaction) into behavioural tests against real collaborators, and it combines the
harness with the tests rather than landing the harness first.

The alias list and the reuse of the existing setupMwMock follow the issue as written. hexRegex.js has no spec of
its own here; it is covered only through the components that use it.

A correction to one premise

The issue notes that RedHerb's CommonJS Options API form is something "the vue plugin handles fine". It does not.
Loading a RedHerb component in vitest without a transform fails with Cannot find module 'vue', because require()
falls through to Node's resolver rather than Vite's, and RedHerb has no node_modules of its own. Separately, a
<script> block that assigns module.exports compiles to a component with no default export. Both had to be
rewritten, which is what resourceLoaderCommonJs.ts does. Any option on the vitest axis needs this.

The axis the issue did not cover

A and B are both node-side. The third option is running the tests through ResourceLoader itself, as a MediaWiki
QUnit test module served by Special:JavaScriptTest.

vitest (A or B) MediaWiki QUnit
Module loading Needs a CommonJS to ESM transform to stand in for ResourceLoader ResourceLoader is the loader, so no transform
ext.neowiki Aliased to the TypeScript source, or to the built bundle The real module the wiki serves
Assertion tooling @vue/test-utils Manual mount and DOM inspection
Infrastructure Already present, runs in about 80 seconds No browser test job exists in this repo today
Copyable with RedHerb No, needs this repo's node toolchain Yes, the test module travels in extension.json

The tension

Catching NeoWiki breaking its own examples requires running against NeoWiki's real API. Tests somebody can run after
copying RedHerb into their own extension must not depend on this repo. Those pull opposite ways: stubbing
ext.neowiki to make the specs portable would mean an API change no longer breaks them, which is the thing the issue
asks to catch.

Options that resolve it, rather than picking a side:

  1. Option A with ext.neowiki resolved to the built bundle rather than to src/public-api.ts. The transform
    ships inside RedHerb, so it becomes part of the example rather than hidden config, and a copier repoints one alias
    at their own NeoWiki install. Costs: RedHerb gains vitest, @vue/test-utils, vue and Codex as devDependencies
    plus a lockfile update, and the tests depend on a prior build. The bundle is not committed today.
  2. Keep the behavioural specs on the node side and add a small QUnit module to RedHerb as the copyable example.
    Costs a browser job in CI, which does not exist yet, though ci-php already stands up MediaWiki and Neo4j.

Worth noting that no node-side option can see the ResourceLoader wiring itself: a file missing from packageFiles, a
missing codexComponents entry, an unregistered message. A PHPUnit assertion that every relative require() in
RedHerb's resources appears in its packageFiles would cover most of that far more cheaply than a browser job.

No decision taken here. Happy to reshape this PR toward A, split the harness out first as originally proposed, or
narrow the tests back to the smoke-test scope.

AI-authored, Claude Code Opus 5 (1M context); written at @malberts' request to summarise a design discussion held in chat; not yet reviewed by a human. Repository claims above (absence of a browser test job, uncommitted bundle) were checked in the working tree; the loader failures were reproduced.

alistair3149 added a commit that referenced this pull request Sep 10, 2026
For #1390

Both RedHerb editors read with `getSubject()` and save the result through `nw.useSubjectStore()`.
That was right while `getSubject()` meant the current revision; now it means the published one, so on
an approval wiki the editor opens on approved values and the save writes them over the pending draft.

RedHerb is what an extension author copies, and `extending.md` said only to read through the
repositories, so it now names which read. `rest-api.md` spells `latest=1`: the REST validator reads a
valueless `latest` as false, so the bare parameter the row showed asks for the published revision.

RedHerb's Vue components have no test coverage
(#1218), so these two call sites are unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaTULCniBvK8S53astfRX
alistair3149 added a commit that referenced this pull request Sep 10, 2026
For #1390

Both RedHerb editors read with `getSubject()` and save the result through `nw.useSubjectStore()`.
That was right while `getSubject()` meant the current revision; now it means the published one, so on
an approval wiki the editor opens on approved values and the save writes them over the pending draft.
Neither is gated on the page being at its current revision, so both reach that state.

RedHerb is what an extension author copies, and `extending.md` said only to read through the
repositories, so it now names which read. `rest-api.md` spells `latest=1`: the REST validator reads a
valueless `latest` as false, so the bare parameter the row showed asks for the published revision.

RedHerb's Vue components have no test coverage
(#1218), so these two call sites are unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaTULCniBvK8S53astfRX
alistair3149 added a commit that referenced this pull request Sep 10, 2026
For #1390

Both RedHerb editors read with `getSubject()` and save the result through `nw.useSubjectStore()`.
That was right while `getSubject()` meant the current revision; now it means the published one, so on
an approval wiki the editor opens on approved values and the save writes them over the pending draft.
Neither is gated on the page being at its current revision, so both reach that state.

RedHerb is what an extension author copies, and `extending.md` said only to read through the
repositories, so it now names which read. `rest-api.md` spells `latest=1`: the REST validator reads a
valueless `latest` as false, so the bare parameter the row showed asks for the published revision.

RedHerb's Vue components have no test coverage
(#1218), so these two call sites are unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaTULCniBvK8S53astfRX
JeroenDeDauw pushed a commit that referenced this pull request Sep 10, 2026
* Serve the latest read from the revision its gate cleared

For #1390

`GET /subject/{id}?latest=1` gated the page's latest revision and then had
`MediaWikiSubjectRepository` go find that page and revision again, and `expand=relations` reached
every relation target's page at its own latest revision. So an approval wiki served drafts of pages
the caller never named, and a save landing between the gate and the read was served unchecked.

`LatestRevisionSubjectLookup` answers from the revision the handler holds, and asks
`PublishedSubjectLookup` only for the ids that revision does not hold. Subjects on the page the gate
cleared therefore come from it, including a relation target on that same page; the pages the caller
said nothing about answer with what they publish. `PointInTimeSubjectLookup` composes the same way
for the `revisionId` read.

One lookup serves both the requested Subject and the ones `expand=relations` reaches, which also
keeps a Subject that targets itself answering as itself: `GetSubjectQuery` places the requested
Subject and then the relation loop keys the same id, so two lookups there would have answered for it
from the wrong one.

Two behaviour changes fall out. `latest=1` on a page whose subject slot holds another content model
answers not-found rather than 500, matching the published read; the write path keeps refusing such a
slot, since it would save over it. And a page whose latest revision has suppressed text answers
not-found rather than 500. The 500 survives elsewhere on all three reads: the Main-Subject lookup
behind the display-name fallback still reads through the write repository, which throws for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaTULCniBvK8S53astfRX

* Seed the Subject editor from the revision its save will target

For #1390

`getSubjectForEditing()` sent `latest=1` only when the repository had no revision to pin, and
`NeoWikiExtension.getRevisionId()` pins one whenever `wgRevisionId` differs from `wgCurRevisionId`.
A write targets the page's current revision whatever revision was read, so an editor seeded from a
pinned one saves those values over whatever the page holds now.

`NeoWikiApp` gates `canEditSubject` on that same comparison, so no View offers editing on a pinned
view and the two branches never both applied there. A host that mounts an editor itself has no such
gate: RedHerb's main-subject dialog is one, and the BlueSpice connector another. On an approval wiki
a pinned view is the ordinary article view of a page with a pending draft, so there an ungated editor
loaded approved values and wrote them over that draft.

An editing read is now never pinned. A display read still follows the revision the page is rendered
at, which is what the reader came for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaTULCniBvK8S53astfRX

* Point the extension example at the editing read

For #1390

Both RedHerb editors read with `getSubject()` and save the result through `nw.useSubjectStore()`.
That was right while `getSubject()` meant the current revision; now it means the published one, so on
an approval wiki the editor opens on approved values and the save writes them over the pending draft.
Neither is gated on the page being at its current revision, so both reach that state.

RedHerb is what an extension author copies, and `extending.md` said only to read through the
repositories, so it now names which read. `rest-api.md` spells `latest=1`: the REST validator reads a
valueless `latest` as false, so the bare parameter the row showed asks for the published revision.

RedHerb's Vue components have no test coverage
(#1218), so these two call sites are unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmaTULCniBvK8S53astfRX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Add tests for Vue components in RedHerb

1 participant