[OP-19617] Add a test seam to the Turbo integration#23980
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves maintainability and testability of the Turbo integration by (1) introducing an explicit “test seam” for event listener registration via optional AbortSignal (to allow clean teardown in unit tests) and (2) adding Vitest coverage for several Turbo-related behaviors, including a “tripwire” spec guarding a monkeypatch for hotwired/turbo#1300.
Changes:
- Add a documented monkeypatch for Turbo frame navigation/history snapshot behavior and a spec that fails loudly on upstream API/behavior changes.
- Update Turbo listener registration helpers to accept an optional
AbortSignal(and targetDocument) and add unit tests for key listener behaviors. - Add unit tests for custom Turbo Stream actions (flash, dispatch event, input caption, live region).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/turbo/turbo-navigation-patch.ts | Adds detailed rationale/docs for the Turbo #1300 workaround and implements live-document snapshot sourcing. |
| frontend/src/turbo/turbo-navigation-patch.spec.ts | Adds a “tripwire” spec to detect upstream changes that should trigger patch removal/review. |
| frontend/src/turbo/turbo-global-listeners.ts | Adds target/signal parameters and uses signal for listener teardown support. |
| frontend/src/turbo/turbo-global-listeners.spec.ts | Adds unit test for the OPCE morph guard behavior. |
| frontend/src/turbo/turbo-event-listeners.ts | Adds target/signal parameters and uses signal for listener teardown support. |
| frontend/src/turbo/turbo-event-listeners.spec.ts | Adds unit tests for dialog-close behavior and nonce header injection. |
| frontend/src/turbo/live-region-stream-action.spec.ts | Adds unit tests for the live-region stream action behavior. |
| frontend/src/turbo/input-caption-stream-action.spec.ts | Adds unit tests for input caption stream action behavior. |
| frontend/src/turbo/flash-stream-action.spec.ts | Adds unit tests for flash stream action append/replace behavior. |
| frontend/src/turbo/dispatch-event-stream-action.spec.ts | Adds unit tests for dispatch-event stream action behavior. |
| frontend/src/turbo/action-menu-morph-remount.ts | Adds target/signal parameters and uses signal for listener teardown support. |
| frontend/src/turbo/action-menu-morph-remount.spec.ts | Adds unit tests ensuring action-menu hosts are remounted only when needed. |
Comments suppressed due to low confidence (1)
frontend/src/turbo/turbo-event-listeners.ts:17
- In the turbo:submit-end handler, destructuring
targetfrom the event shadows the function parametertarget(the document you attach listeners to), which is confusing now that the outer function takes atarget: Document. Also,closest('dialog')!defeats TypeScript’s nullability checks while the code still relies on a runtime null check.
Rename the destructured variable and remove the non-null assertion to keep intent clear and types accurate.
target.addEventListener('turbo:submit-end', (event) => {
const { detail: { success }, target } = event;
if (success && target instanceof HTMLFormElement) {
const dialog = target.closest('dialog')!;
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
ebfd557 to
75ff281
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
frontend/src/turbo/turbo-event-listeners.ts:17
- In this handler, destructuring
targetfrom the event now shadows the function parametertarget(the Document). This makes the code harder to read and also prevents using the passed-in Document for thedialog:closedispatch (useful for the new test seam). Consider renaming the event target and dispatching on the passed-intargetDocument.
target.addEventListener('turbo:submit-end', (event) => {
const { detail: { success }, target } = event;
if (success && target instanceof HTMLFormElement) {
const dialog = target.closest('dialog')!;
75ff281 to
20c9900
Compare
|
Caution The provided work package version does not match the core version Details:
Please make sure that:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
frontend/src/turbo/turbo-event-listeners.ts:17
addTurboEventListenersnow accepts atargetDocument for testability, but theturbo:submit-endhandler still dispatchesdialog:closeon the globaldocument. Because the handler also destructurestargetfrom the event (shadowing the outertarget), this makes the new seam inconsistent and would dispatch to the wrong document if a non-default target is ever provided (e.g., an iframe document in tests).
target.addEventListener('turbo:submit-end', (event) => {
const { detail: { success }, target } = event;
if (success && target instanceof HTMLFormElement) {
const dialog = target.closest('dialog')!;
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
aa671d1 to
479b08d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
frontend/src/turbo/turbo-event-listeners.ts:17
addTurboEventListenersnow accepts atargetdocument, but theturbo:submit-endhandler destructurestargetfrom the event, which shadows the outertargetparameter. This makes the code harder to follow and also prevents dispatchingdialog:closeon the injected target (it currently always dispatches on the globaldocument). Renaming the event target (e.g.,eventTarget) and dispatching via the outertargetkeeps the test seam consistent.
target.addEventListener('turbo:submit-end', (event) => {
const { detail: { success }, target } = event;
if (success && target instanceof HTMLFormElement) {
const dialog = target.closest('dialog')!;
The Turbo integration listeners bound to the global `document` and returned `void`, so the most Turbo-upgrade-fragile frontend code had no unit coverage beyond a single Selenium navigation spec. Threads an optional event target and `AbortSignal` through the installers so vitest can drive synthetic `turbo:*` events and tear the listeners down between cases. Adds specs for the seamed installers and all five custom stream actions; callers stay unchanged through the defaults.
The frame-visit monkeypatch reached into Turbo internals behind `@ts-nocheck` and a blanket eslint-disable, so neither the compiler nor a test would notice a Turbo upgrade renaming the method or fixing hotwired/turbo#1300 underneath it. Documents the single divergence from upstream -- sourcing the history snapshot from the live document rather than the fetch response -- pins it to Turbo 8.0.x, and adds a tripwire spec that fails on either upgrade so the patch is re-evaluated instead of silently breaking.
The vitest tripwire guards the patched method's upstream shape but not its runtime effect. This adds the behavioural half on the departments page, whose content area is a `data-turbo-action="advance"` frame: advancing through the tree and pressing back must restore the parent frame. Without `turbo-navigation-patch.ts` the URL reverts while the frame keeps the child content, so the exact breadcrumb match fails. The bug only reproduces with Rails-rendered frames, which is why it cannot live in the vitest suite.
The wrapper destroys and re-bootstraps the whole Angular root on every Turbo navigation -- the most consequential frontend/src/turbo/ installer yet the only one left without coverage. Threads the same (target, signal) seam and injects the plugin-context lookup and bootstrap call so vitest can drive synthetic turbo:load events. The RxJS skip(1) pipeline and destroy-then-bootstrap contract are unchanged; callers stay on the defaults.
479b08d to
b660548
Compare
|
Warning Flaky specs
🤖 Ask Copilot to investigateCopy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer. |
Ticket
https://community.openproject.org/wp/OP-19617
What are you trying to accomplish?
The Turbo integration is the most upgrade-fragile frontend code we have — listeners bound to the global
document, custom stream actions, and a monkeypatch reaching into Turbo's privateFrameControllerbehind@ts-nocheck— yet it had no unit coverage beyond a single Selenium navigation spec. A Turbo upgrade could rename an internal method or fix a bug underneath us and neither the compiler nor a test would notice.This PR adds that missing coverage and pins the patch to the Turbo version it was written against.
What approach did you choose and why?
Three commits, smallest blast radius first:
Add a test seam to the Turbo integration. The installers bound to the global
documentand returnedvoid, so vitest had no way to drive syntheticturbo:*events or tear listeners down between cases. This threads an optional event target andAbortSignalthrough the installers; callers stay unchanged through the defaults. Adds specs for the seamed installers and all five custom stream actions.Guard the Turbo navigation monkeypatch. The frame-visit patch reached into Turbo internals behind
@ts-nocheckand a blanket eslint-disable. This documents the single divergence from upstream (sourcing the history snapshot from the live document rather than the fetch response), pins it to Turbo 8.0.x, and adds a tripwire spec that fails on either a method rename or an upstream fix — so the patch is re-evaluated instead of silently breaking. The upstream fix is in flight as Fix back-button stale frame on advance navigation hotwired/turbo#1549; once it ships in a release we depend on, this patch can be dropped.Cover the turbo#1300 back-button fix. The vitest tripwire guards the patched method's shape but not its runtime effect. This adds the behavioural half on the departments page, whose content area is a
data-turbo-action="advance"frame: advancing through the tree and pressing back must restore the parent frame. The bug only reproduces with Rails-rendered frames, which is why it cannot live in the vitest suite.Seam the Angular-Turbo re-bootstrap.
turbo-angular-wrapper.ts— which destroys and re-bootstraps the whole Angular root on every Turbo navigation — was the onefrontend/src/turbo/installer left without coverage, and the most consequential: agetPluginContexttiming change or a Turbo upgrade can leave everyopce-*element inert after the first navigation with nothing to catch it. This threads the same(target, signal)seam and injects the plugin-context lookup and bootstrap call, so vitest can drive syntheticturbo:loadevents withoutwindow.OpenProjector a real Angular application. The RxJSskip(1)pipeline and the destroy-then-bootstrap contract are unchanged; callers stay on the defaults.Merge checklist