Compare commits

...
Author SHA1 Message Date
james.maes 7ff90f1a63 chore(GH-985): integrate ESB acceptance with browser fixes 2026-10-01 18:46:40 -05:00
james.maes 50a935a4a5 Merge pull request #19 from QRun-IO/feature/GH-10-esb-acceptance
fix(esb): finish management and real-broker acceptance (#10)
2026-10-01 18:30:11 -05:00
james.maes 40e987d4d0 Merge pull request #20 from QRun-IO/feature/GH-985-next-tracking
docs(tracking): manage Next UI through the QQQ project
2026-10-01 18:09:28 -05:00
james.maes 71f5a39b09 docs(tracking): centralize Next UI delivery and unreleased fixes 2026-10-01 18:04:34 -05:00
james.maes 45f99f27ce fix(auth): finish logout before allowing another sign-in
Keep authentication loading through cookie expiry and provider departure (QRun-IO/qqq#984).
2026-10-01 17:40:38 -05:00
james.maes d17e880e79 test(widgets): recognize proven missing script responses 2026-10-01 17:25:44 -05:00
james.maes f8fdb8e997 fix(esb): finish management and real-broker acceptance (#10) 2026-10-01 17:23:24 -05:00
james.maes ad154b5b56 test(acceptance): observe browser framing refusal (#981) 2026-10-01 17:22:43 -05:00
james.maes c6a58933a6 test(#979): account for Linux WebKit download filenames
Verify original application filenames and preserve downloaded data checks while matching the pinned libsoup space sanitization.
2026-10-01 17:16:51 -05:00
james.maes 26e3a0d004 test(acceptance): await initial document fonts before redirect
Complete the rendered initial document before NAV016 tears it down (QRun-IO/qqq#951).
2026-10-01 17:08:48 -05:00
james.maes c9d7fcd393 fix(forms): preserve option pointer targets in WebKit 2026-10-01 17:06:35 -05:00
james.maes b3e1ddda8b test(browser): adopt fixed WebKit network library and retain crash diagnostics
Refs QRun-IO/qqq#904, QRun-IO/qqq#960, QRun-IO/qqq#973. Preserve final RC9 tag outcomes separately from current verification.
2026-10-01 17:06:23 -05:00
james.maes 8d4668cdab test(acceptance): await breadcrumb destination iframe
Require the owned chart heading before leaving Greetings App so navigation does not cancel its pending document. Refs QRun-IO/qqq#972.
2026-10-01 16:53:35 -05:00
james.maes 254a0a2050 test(reports): await summary metadata before navigation
Refs QRun-IO/qqq#961.
2026-10-01 16:39:39 -05:00
james.maes 52cc038fa6 test(acceptance): observe transient Firefox feedback reliably
Capture toast visibility before actions and verify navigation-cancelled legacy fonts in the final document. Refs QRun-IO/qqq#951, QRun-IO/qqq#953, QRun-IO/qqq#967.
2026-10-01 16:39:23 -05:00
james.maes 6090f2f9b4 fix(search): preserve focus after early keyboard interaction
Refs QRun-IO/qqq#950.
2026-10-01 16:32:48 -05:00
james.maes 32a9560970 Merge pull request #17 from QRun-IO/feature/rc9-publication-notes
docs: record verified RC9 public delivery
2026-10-01 14:29:27 -05:00
james.maes 96085c72e2 docs(release): record verified RC9 public delivery
Refs QRun-IO/qqq#712. Preserve immutable source and distinguish active tag acceptance from completed checks.
2026-10-01 14:28:27 -05:00
james.maes c029ad2a70 Merge pull request #14 from QRun-IO/feature/next-1.0
Next UI 1.0 RC9 integration: compatibility, verification and issue tracking
2026-10-01 14:12:28 -05:00
james.maes 32a3b281ff chore(release): prepare Next UI 1.0.0-RC.9
Refs QRun-IO/qqq#712 and QRun-IO/qqq#713. Preserve known issue disclosures and the final release hold.
2026-10-01 14:11:48 -05:00
james.maes a0a18cfa76 Merge pull request #16 from QRun-IO/feature/next-issue-tracking
docs: make GitHub the Next UI source of truth
2026-10-01 14:06:09 -05:00
69 changed files with 2337 additions and 289 deletions

No files matched your search

+2
View File
@@ -72,6 +72,8 @@ jobs:
- name: Acceptance (${{ matrix.browser }})
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
+3 -3
View File
@@ -2,7 +2,7 @@
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
[Next UI 1.0.0-RC.8 is published](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.8) for application testing. Installation requirements, verified artifacts and known gaps are in the [RC8 notes](docs/releases/1.0.0-RC.8.md). Final 1.0 remains held for owner application testing and explicit approval.
[Next UI **1.0.0-RC.9** is published](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) for application testing. Public Maven and AMD64/ARM64 container delivery are verified; requirements, verification scope and known gaps are in the [RC9 notes](docs/releases/1.0.0-RC.9.md). Final 1.0 remains held for owner application testing and explicit approval.
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
@@ -14,11 +14,11 @@ The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.8</version>
<version>1.0.0-RC.9</version>
</dependency>
```
The explicit version overrides an older BOM-managed frontend. RC8 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The explicit version overrides an older BOM-managed frontend. RC9 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The jar holds the static export (`pnpm build:export`) under `next-dashboard/`. Deep links such as `/app/person/1` are served from placeholder pages and read their route from the browser path, so no Node.js server is involved. The Material Dashboard remains available: select it with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`. Material routes are `/<app>/<table>/<id>`; Next routes are `/app/<table>/<id>`.
+31 -3
View File
@@ -10,12 +10,36 @@ GitHub issues are the source of truth for scope, current status, remaining work
| Feature acceptance and default adoption | [qqq#649](https://github.com/QRun-IO/qqq/issues/649) |
| Behavior and metadata compatibility | [qqq#714](https://github.com/QRun-IO/qqq/issues/714) |
| Original Next visual review and owner approval | [qqq#711](https://github.com/QRun-IO/qqq/issues/711) |
| Browser failures and incomplete run evidence | [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) |
| Browser failures and incomplete run evidence | [qqq#985](https://github.com/QRun-IO/qqq/issues/985) |
| Real application soak and final release hold | [qqq#712](https://github.com/QRun-IO/qqq/issues/712) |
| Cross-application launch/restart acceptance | [qqq#650](https://github.com/QRun-IO/qqq/issues/650) |
| ESB integration and acceptance | [qqq#986](https://github.com/QRun-IO/qqq/issues/986) |
| Paired runtime provenance and CI handoff | [qqq#905](https://github.com/QRun-IO/qqq/issues/905) |
Use existing issues for the same requirement or failure. Create focused Next release issues in QRun-IO/qqq and link them to #713 and their feature parent. Keep existing frontend-repository issues linked; do not duplicate their scope.
Create **all Next UI tickets in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)** and add them to the [QQQ Roadmap, Project 12](https://github.com/orgs/QRun-IO/projects/12). Use existing issues for the same requirement or failure, and link each focused ticket to #713 and its feature or investigation parent. Frontend issues #12 and #10 were transferred to central issues #985 and #986; use the central identifiers and preserve their report history.
## Required ticket fields
Every new ticket needs these fields on the issue and its Project 12 item; complete them before work advances to **Ready**:
| Field | Required value |
|---|---|
| Issue Type | Native issue: Bug, Feature, Task or Epic. Mirror it in the Project field, using Planning Epic for Epic; Improvement is a Project-only category. |
| Assignees | The person accountable for completing or coordinating the ticket |
| Milestone | The agreed delivery milestone; keep it consistent with the target version |
| Priority | Critical, High, Medium or Low, with an impact-based rationale in the issue |
| Component | The owning implementation component: `qqq-frontend-next` for Next source, or the actual backend/sample component for a dependency. |
| Target Version | The agreed version of the owning component or an explicit pending release decision. A backend dependency in the Next UI 1.0 milestone does not imply a backend 1.0 release. Never imply delivery in an already published RC. |
| Status | The current workflow state below |
Record unresolved ownership or release decisions explicitly while a ticket remains in **Backlog**. Do not invent dates, estimates or a new release version to fill a field. A browser assertion failure alone does not establish high product severity.
## Workflow and evidence
- **Backlog / Ready:** capture the expected behavior, scope, acceptance criteria, required fields and dependencies; Ready means actionable work with an owner.
- **In progress:** link the active branch or work and record reproduction, confirmed cause, implementation progress and remaining blockers.
- **In review:** link the reviewable PR, source commit and verification evidence; keep unmet acceptance criteria visible. A local fix or passing subset does not imply merge or release.
- **Done:** close only after the accepted fix is integrated and every acceptance criterion is verified, or an explicit approved disposition is recorded. Link the merged PR/commit and evidence. Identify whether the change is unreleased or delivered, with the version and release link when published.
## Updating an issue
@@ -30,4 +54,8 @@ Use existing issues for the same requirement or failure. Create focused Next rel
Preserve the original Next design at `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard is the behavior and metadata compatibility reference. Prioritize primary-browser functional depth and disclose secondary-browser failures.
RC8 is a prerelease for application testing. Final 1.0 remains held for the owner's requested soak and explicit final approval. This project does not authorize releasing QQQ 4.1.
[RC9](releases/1.0.0-RC.9.md) is the published prerelease for application testing. Its immutable-tag acceptance run has known Firefox/tablet failures; consult the release notes and #985 for the exact evidence. The browser fixes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are [unreleased](releases/unreleased.md).
Every release and changelog entry must link its central QQQ issues and distinguish product changes, test/infrastructure corrections, verification and unresolved work. A published RC's tag and artifacts remain immutable; subsequent fixes require a separately authorized version and release. Update tickets and Project 12 with the actual delivery version and release link after publication.
Final 1.0 remains held for the owner's requested application soak and renewed, explicit final approval ([#712](https://github.com/QRun-IO/qqq/issues/712)). Passing CI, an issue marked Done or elapsed time does not release that hold. This project does not authorize releasing QQQ 4.1.
+4 -4
View File
@@ -71,11 +71,11 @@ QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
QQQ_ACCEPTANCE_BROWSERS=mobile,tablet node scripts/acceptance.mjs --skip-build
```
| Project | Playwright device | Engine (Playwright 1.58.2) | Runs |
| Project | Playwright device | Engine (Playwright 1.64.0-alpha-2026-10-01) | Runs |
|----------|-------------------|----------------------------|------|
| chromium | Desktop Chrome | Chrome for Testing 145 | every spec |
| firefox | Desktop Firefox | Firefox 146 | every spec |
| webkit | Desktop Safari | WebKit 26.0 | every spec |
| chromium | Desktop Chrome | Chrome for Testing 155 | every spec |
| firefox | Desktop Firefox | Firefox 156 | every spec |
| webkit | Desktop Safari | WebKit 26.6 | every spec |
| mobile | Pixel 7 (412 × 839, touch) | Chromium | tests tagged `@mobile` |
| tablet | iPad (gen 7) (810 × 1080, touch) | WebKit | tests tagged `@mobile` or `@tablet` |
+5
View File
@@ -0,0 +1,5 @@
# ESB integration acceptance
The ten required [ESB matrix scenarios](../../tests/acceptance/matrix/esb.json) pass in Chromium, Firefox and WebKit: 30 cases with no failures, skips or retries. They exercise the stock sample’s embedded Artemis broker, table-event delivery into `syncPerson`, service/table/process views, permission denial, subscription browsing, trigger pause/resume/restart and confirmed dead-letter replay. A further 38 Chromium navigation, permission and table Developer-view cases, plus 12 security-variant and search cases, pass without skips or retries. The main and security fixtures use separate broker ports. These are focused ESB results; general Next browser/parity and final release acceptance remain tracked separately.
The backend integration fixes are [QQQ PR #980](https://github.com/QRun-IO/qqq/pull/980), based on `5bf02d54`: normal sample ESB registration, API documentation route isolation and nullable subscription/dead-letter pause state. Frontend verification passes 105 focused ESB tests, all 1,951 unit tests with coverage thresholds, type checking, the production static export and bundle budgets. The [acceptance README](../../tests/acceptance/README.md) gives the commands and fixture boundaries; [Next #10](https://github.com/QRun-IO/qqq-frontend-next/issues/10) and [QQQ #739](https://github.com/QRun-IO/qqq/issues/739) track delivery. RabbitMQ and broker management behavior are covered by the backend conformance suite; these browser tests use Artemis without an HTTP management endpoint, so unavailable broker actions remain hidden.
+2
View File
@@ -12,6 +12,8 @@ Recorded runs: `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-result
Corrected RC8 source cd3d473: 602 local and 602 hosted Chromium cases passed, with zero failed/skipped/flaky cases or report errors. Hosted run 36798799502. Three approved external-service exclusions remain. Earlier failed runs are retained and described in docs/releases/1.0.0-RC.8.md; this matrix records the corrected primary runs only. Other browser profiles and whole-product compatibility/visual approval remain separate.
The additional ESB integration scenarios and their focused three-browser results are recorded in [ESB acceptance](esb.md). The historical full-suite counts above predate those ten new matrix rows.
## Navigation, application structure, branding and icons
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
+1 -1
View File
@@ -2,7 +2,7 @@
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the latest published prerelease is [1.0.0-RC.8](../releases/1.0.0-RC.8.md) at `cd3d473`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the latest published prerelease is [1.0.0-RC.9](../releases/1.0.0-RC.9.md) at `c029ad2`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
## Operating rule: metadata is the product boundary
+3 -1
View File
@@ -1,8 +1,10 @@
# Next UI developer guide
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The latest published prerelease is [1.0.0-RC.8](../releases/1.0.0-RC.8.md) (commit `cd3d473`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The latest published prerelease is [1.0.0-RC.9](../releases/1.0.0-RC.9.md) (commit `c029ad2`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
+4 -11
View File
@@ -10,23 +10,16 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
Latest published candidate: **[1.0.0-RC.7](1.0.0-RC.7.md)**. Its notes record installation, backend requirements, exact artifact verification and known failures. RC1 through RC6 remain available and unchanged.
Latest published candidate: **[1.0.0-RC.9](1.0.0-RC.9.md)**. Its notes record installation, backend requirements, exact artifact verification and known failures. RC1 through RC8 remain available and unchanged.
## Fixes during testing
Development and verification continue on `feature/next-1.0`, tracked in [PR #14](https://github.com/QRun-IO/qqq-frontend-next/pull/14). The branch can include changes beyond the latest published RC. Check the version of each preview separately; a local preview may intentionally preserve an earlier design checkpoint. Branch changes are **not retroactive changes to published RC artifacts**.
Browser stability work continues on `feature/browser-stability`, tracked in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) and the [unreleased changelog](unreleased.md). These changes are not present in the published RC9 artifacts. Earlier PRs #14 and #16 are merged. Check the version of each preview separately; a local preview may intentionally preserve an earlier design checkpoint.
The branch includes subsequent report editor fixes, script completions, and corrections found in the original Next UI visual review. The latest visual corrections restore Copy form alignment and selected-section defaults, and expose the full table checkbox target on touch devices. Verification details are in the [visual review](../acceptance/next-ui-visual-review.md). The review remains open for other screen families.
For the complete change history, compare the immutable RC1 tag with the branch:
```sh
git fetch origin tag v1.0.0-RC.1
git log --oneline v1.0.0-RC.1..feature/next-1.0
```
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
## Reporting application issues
Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an RC1 issue already fixed on the branch from a new regression. Never include credentials or sensitive application records.
Create or update the central QQQ ticket and Project 12 item using the [tracking policy](../ISSUE-TRACKING.md). Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an issue in a published RC from a later branch regression. Never include credentials or sensitive application records.
The compatibility ledger and remaining acceptance work still govern final readiness. The testing hold does not waive those requirements or authorize a QQQ 4.1 release.
+41
View File
@@ -0,0 +1,41 @@
# Next UI 1.0.0-RC.9
Release candidate for application testing, October 1, 2026. The [GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) records public artifact availability and final delivery verification; a source tag alone is not delivery confirmation. Final 1.0 remains held for owner application testing and explicit approval.
## Changes from RC8
RC9 integrates the GitHub tracking policy, current roadmap links, and updated developer/agent documentation. Application code and dependencies are unchanged from RC8. This candidate does not claim fixes for the open functional or visual findings.
[QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713) is the source of truth for current status and remaining work. Original issue reports and historical acceptance evidence remain available. See the [tracking policy](../ISSUE-TRACKING.md).
## Verification baseline and known issues
The documentation source `354ee2709d8ee553de5e3ad823ce9d1190887d4c`, merged through PR #16, passed general CI (typecheck, lint, unit coverage, mocked Chromium E2E, Storybook and bundle budgets) and both container architecture builds. Real-backend [run 36861609503](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36861609503) passed all 602 Chromium cases and all 391 required rows, with three approved real-provider exclusions.
Its secondary reports remain failed: Firefox 599 passed / 3 failed (NAV-014, NAV-016 and REC-056 legacy-font diagnostics, [#951](https://github.com/QRun-IO/qqq/issues/951)), WebKit 601 / 1 (WID-073 metadata access-control diagnostic, [#961](https://github.com/QRun-IO/qqq/issues/961)), and combined touch 1,124 / 2 (tablet NAV-025 and REL-005). Reports contain zero skips/flaky outcomes/report errors. These are pre-RC9-version source results; final tag results and public artifact receipts are recorded separately below. No all-browser pass is claimed.
Current open work includes embedded report/query equivalence, remaining input configurations, subpath hosting, real Auth0/Google Drive/QuickSight checks, browser investigations, original-Next visual approval, real-application soak and final release approval. See [#713](https://github.com/QRun-IO/qqq/issues/713) and [browser evidence](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual issues. Material remains the behavior reference; the original Next design remains the visual reference.
## Versioned local verification
RC9 passes all 1,919 unit tests across 181 files, typechecking, lint/license (zero errors, five existing warnings), production export, Maven packaging and unchanged bundle budgets. All 193 packaged dashboard files are present; the Maven version is RC9 and no mock service worker is included. The actual locally packaged RC9 JAR passes 28 affected desktop/mobile browser cases with zero failures/skips/flaky outcomes/report errors. These checks cover API controls/theme/search focus, exact numeric persistence and composite field submission against backend e0d57dd31. They do not replace public artifact verification or certify other profiles.
## Installation
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.9`.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.9` (AMD64/ARM64 verified). The container backend origin is fixed at build time; the published image targets `host.docker.internal:8000`. Use the JAR or rebuild for another origin.
As with RC8, verification uses the QQQ 4.1 development backend. The locally verified backend source is `e0d57dd31b979b17215370546ef4e26cd8a38d21`; hosted workflows resolve their configured QQQ ref separately. This release does not publish QQQ 4.1 or certify every real consumer/backend combination.
## Public delivery — October 1, 2026
[RC9](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) was published at 19:25:28 UTC as a prerelease, latest=false. PR16 and PR14 are merged. The signed immutable tag points to `c029ad2a70948aac6469d8a71ccefc80c6e00d74`.
- Public JAR: 1,864,121 bytes; SHA256 `969034566c6b30a5034680dcf2d46aec327240db6cdd3fb7b31f1fae7cb9f17d`; SHA1 `3943c8641c14554999206cf52357c32b8f397b7b`.
- Release signature verified against fingerprint `68A49476754103988EA3ED924B88CBB3D6DCB237`; a clean ordinary Maven consumer resolved identical bytes without manual installation.
- Actual public JAR: all 28 targeted desktop/mobile cases passed, zero failures/skips/flaky outcomes/report errors. All 193 dashboard files and RC9 version markers are present, without the mock service worker.
- Public image: `sha256:c2c684733deb98fd8355d4a9e2a53bf4af429bd16d9e7802641f9ef9d44e1017`. Anonymous AMD64/ARM64 pulls and both architecture startup, route, JavaScript, pinned-font and version checks passed.
- Merged-source general CI36912597438 passed. Fresh full dependency audit reports zero advisories across1,038dependencies; GitHub marked all four outstanding alerts fixed, none dismissed. Issues #647/#698 are closed with that evidence.
Full immutable-tag acceptance [36912641399](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36912641399) completed after publication: Chromium 602 passed / 0 failed; desktop WebKit 602 / 0; Firefox 600 / 2; mobile 563 / 0; tablet 560 / 3. All reports contain zero skips, flaky outcomes and report errors. Firefox failed NAV-016 legacy-font diagnostics and REC-048 validation-toast visibility; tablet failed NAV-015 JavaScript loading, NAV-016 root navigation (both WebKit internal errors), and NAV-025 Clear-search focus in dark mode. The overall run failed; later passing cases do not resolve older investigations. Follow [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual failure tracking and [#713](https://github.com/QRun-IO/qqq/issues/713) for the live backlog. Final 1.0 remains held.
+24
View File
@@ -0,0 +1,24 @@
# Next UI — unreleased
The browser stability changes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are under review and verification. No new version or release is declared here. Published [RC9](1.0.0-RC.9.md) remains immutable and does not contain these fixes. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
## Product fixes
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
## Browser testing and infrastructure
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
## Verification and remaining work
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes; no corrected all-browser pass or release approval is claimed by this changelog. The original causes of the same-user session stall and legacy process-navigation stall remain unresolved in [#952](https://github.com/QRun-IO/qqq/issues/952) and [#960](https://github.com/QRun-IO/qqq/issues/960).
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.8",
"version": "1.0.0-RC.9",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
@@ -60,7 +60,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "^1.58.2",
"@playwright/test": "1.64.0-alpha-2026-10-01",
"@storybook/addon-docs": "10.6.0",
"@storybook/nextjs-vite": "10.6.0",
"@storybook/react": "10.6.0",
+28 -38
View File
@@ -72,7 +72,7 @@ importers:
version: 0.485.0(react@19.2.4)
next:
specifier: 16.3.6
version: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rapidoc:
specifier: 9.3.8
version: 9.3.8
@@ -100,19 +100,19 @@ importers:
devDependencies:
'@axe-core/playwright':
specifier: ^4.11.1
version: 4.11.1(playwright-core@1.58.2)
version: 4.11.1(playwright-core@1.64.0-alpha-2026-10-01)
'@eslint/eslintrc':
specifier: ^3.3.4
version: 3.3.4
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
specifier: 1.64.0-alpha-2026-10-01
version: 1.64.0-alpha-2026-10-01
'@storybook/addon-docs':
specifier: 10.6.0
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
'@storybook/nextjs-vite':
specifier: 10.6.0
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
'@storybook/react':
specifier: 10.6.0
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
@@ -1200,9 +1200,9 @@ packages:
cpu: [x64]
os: [win32]
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
'@playwright/test@1.64.0-alpha-2026-10-01':
resolution: {integrity: sha512-N/mTE29hNg/pw/ega+N+cEvPmAFwrprMxoacDbaO0fDlpyQ6n/SvCRF9dv6mLa1tjcy9oPStW2o7C1HP2Mh8lQ==}
engines: {node: '>=20'}
hasBin: true
'@polka/url@1.0.0-next.29':
@@ -3250,11 +3250,6 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4101,14 +4096,14 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
playwright-core@1.64.0-alpha-2026-10-01:
resolution: {integrity: sha512-Dk7ENTj8unFs3o5KNTIhFXO+JN/YGU4FrQvPmxI0Uri9j/5r8Mk0IzmFkNpvx6n83Kcls38qRdkD/fTdAfWvtA==}
engines: {node: '>=20'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
playwright@1.64.0-alpha-2026-10-01:
resolution: {integrity: sha512-Nbe+dTDPKcZjI+lIUe6WLUb3KQ3Wll/SPkf7oCzytUoPZo+wzZY6J2DaD8+7dXMsq+8lUB2dZOMimouiYwpGuw==}
engines: {node: '>=20'}
hasBin: true
possible-typed-array-names@1.1.0:
@@ -5066,10 +5061,10 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
'@axe-core/playwright@4.11.1(playwright-core@1.58.2)':
'@axe-core/playwright@4.11.1(playwright-core@1.64.0-alpha-2026-10-01)':
dependencies:
axe-core: 4.11.1
playwright-core: 1.58.2
playwright-core: 1.64.0-alpha-2026-10-01
'@babel/code-frame@7.29.0':
dependencies:
@@ -5884,9 +5879,9 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
optional: true
'@playwright/test@1.58.2':
'@playwright/test@1.64.0-alpha-2026-10-01':
dependencies:
playwright: 1.58.2
playwright: 1.64.0-alpha-2026-10-01
'@polka/url@1.0.0-next.29': {}
@@ -6386,18 +6381,18 @@ snapshots:
dependencies:
react: 19.2.4
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))':
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))':
dependencies:
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
'@storybook/react': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
'@storybook/react-vite': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4)
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -8369,9 +8364,6 @@ snapshots:
fraction.js@5.3.4: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -9053,7 +9045,7 @@ snapshots:
neotraverse@1.0.1: {}
next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.3.6
'@swc/helpers': 0.5.23
@@ -9072,7 +9064,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.6
'@next/swc-win32-arm64-msvc': 16.3.6
'@next/swc-win32-x64-msvc': 16.3.6
'@playwright/test': 1.58.2
'@playwright/test': 1.64.0-alpha-2026-10-01
sharp: 0.35.4(@types/node@22.19.11)
transitivePeerDependencies:
- '@babel/core'
@@ -9269,13 +9261,11 @@ snapshots:
picomatch@4.0.7: {}
playwright-core@1.58.2: {}
playwright-core@1.64.0-alpha-2026-10-01: {}
playwright@1.58.2:
playwright@1.64.0-alpha-2026-10-01:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
playwright-core: 1.64.0-alpha-2026-10-01
possible-typed-array-names@1.1.0: {}
@@ -10180,13 +10170,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-storybook-nextjs@10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)):
vite-plugin-storybook-nextjs@10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)):
dependencies:
'@next/env': 16.3.6
'@rollup/pluginutils': 5.4.0(rollup@4.63.5)
magic-string: 0.30.21
module-alias: 2.3.4
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pathe: 2.0.3
probe-image-size: 7.4.0
semver: 7.8.5
+1 -1
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.8</revision>
<revision>1.0.0-RC.9</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
+1
View File
@@ -29,6 +29,7 @@ const server = spawn('java', [
'-Dqqq.sample.mockAuthentication=true',
'-Dqqq.sample.sharing=true',
`-Dqqq.sample.port=${port}`,
`-Dqqq.sample.esb.port=${process.env.QQQ_ACCEPTANCE_ESB_PORT ?? '61616'}`,
'-Duser.timezone=UTC',
// javalin mode: the fresh export shadows any dashboard bundled in the sample jar
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, fixtureClasspath].join(path.delimiter),
+6 -1
View File
@@ -17,7 +17,7 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
@@ -124,6 +124,11 @@ const greeting = {
}
describe('SlugPage process initialization', () => {
beforeAll(async () => {
// Keep cold screen compilation outside the metadata-readiness assertion deadline.
await import('@/components/process/ProcessRun')
})
beforeEach(() => {
vi.restoreAllMocks()
params.slug = greeting.name
+135 -13
View File
@@ -24,11 +24,16 @@ import { toast } from 'sonner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EsbCapabilities, EsbPermissions } from '@/types'
import { processInit } from '@/lib/api/processes'
import { processInit, processStatus } from '@/lib/api/processes'
import { orderEsb } from '@/mocks/fixtures/esb'
import { EsbQueueActions, EsbTriggerActions, destinationQueue, type EsbQueueRef } from './EsbActions'
import {
EsbQueueActions,
EsbTriggerActions,
destinationQueue,
type EsbQueueRef,
} from './EsbActions'
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn() }))
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
vi.mock('sonner', async (importOriginal) => ({
...(await importOriginal<typeof import('sonner')>()),
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
@@ -102,28 +107,44 @@ describe('EsbActions', () => {
it('needs esbOperate for pausing a queue and esbDelete for purging and deleting', () => {
const { unmount } = render(
<QueryClientProvider client={new QueryClient()}>
<EsbQueueActions queue={fulfillmentQueue()} permissions={{ canOperate: true, canDelete: false }} />
<EsbQueueActions
queue={fulfillmentQueue()}
permissions={{ canOperate: true, canDelete: false }}
/>
</QueryClientProvider>
)
expect(screen.getByRole('button', { name: 'Pause queue orderFulfillment' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Purge orderFulfillment' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })
).not.toBeInTheDocument()
unmount()
renderWithClient(
<EsbQueueActions queue={fulfillmentQueue()} permissions={{ canOperate: false, canDelete: true }} />
<EsbQueueActions
queue={fulfillmentQueue()}
permissions={{ canOperate: false, canDelete: true }}
/>
)
expect(screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
).toBeInTheDocument()
})
it('offers resume instead of pause for a paused queue', () => {
renderWithClient(
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
)
expect(screen.getByRole('button', { name: 'Resume queue orderFulfillment' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Resume queue orderFulfillment' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
).not.toBeInTheDocument()
})
it('offers pause for a running trigger and resume for a paused one', () => {
@@ -150,10 +171,29 @@ describe('EsbActions', () => {
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Done.'))
})
it('replays all dead letters with esbReplayDeadLetters', async () => {
it.each([1, null])('confirms replay-all with a safe count of %s', async (count) => {
const user = userEvent.setup()
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
renderWithClient(
<EsbTriggerActions
trigger={{
...fulfillTrigger,
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: count },
}}
permissions={ALL}
/>
)
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
let dialog = await screen.findByRole('alertdialog')
expect(dialog).toHaveTextContent('orderFulfillment.dlq')
expect(dialog).toHaveTextContent(
count === null ? 'all dead letters (count unknown)' : '1 message'
)
expect(processInit).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(processInit).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Replay dead letters' }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith('esbReplayDeadLetters', {
values: { triggerName: 'fulfillOrder.orderFulfillment', all: true },
@@ -188,7 +228,9 @@ describe('EsbActions', () => {
const user = userEvent.setup()
renderWithClient(<EsbQueueActions queue={fulfillmentQueue()} permissions={ALL} />)
await user.click(screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' }))
await user.click(
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
)
const dialog = await screen.findByRole('alertdialog')
expect(dialog).toHaveTextContent('orderFulfillment')
expect(dialog).toHaveTextContent('7 messages')
@@ -227,6 +269,86 @@ describe('EsbActions', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Broker unavailable'))
})
it.each([
['Resume Cancel Order', 'esbResumeTrigger', 'cancelOrder.orderEvents'],
['Restart Fulfill Order', 'esbRestartTrigger', 'fulfillOrder.orderFulfillment'],
])('sends the exact trigger input for %s', async (label, processName, triggerName) => {
const user = userEvent.setup()
renderWithClient(
<EsbTriggerActions
trigger={triggerName === cancelTrigger.name ? cancelTrigger : fulfillTrigger}
permissions={ALL}
/>
)
await user.click(screen.getByRole('button', { name: label }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith(processName, { values: { triggerName } })
)
})
it('sends provider and broker queue when resuming delivery', async () => {
const user = userEvent.setup()
renderWithClient(
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
)
await user.click(screen.getByRole('button', { name: 'Resume queue orderFulfillment' }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith('esbResumeQueue', {
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
})
)
})
it('hides replay for an empty dead-letter queue and pause when queue state is unknown', () => {
renderWithClient(
<>
<EsbTriggerActions
trigger={{
...fulfillTrigger,
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: 0 },
}}
permissions={ALL}
/>
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: null }} permissions={ALL} />
</>
)
expect(screen.queryByRole('button', { name: /Replay/ })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Pause queue|Resume queue/ })
).not.toBeInTheDocument()
})
it.each(['COMPLETE', 'ERROR'] as const)(
'keeps controls pending and reports only the final async %s outcome',
async (type) => {
vi.mocked(processInit).mockResolvedValue({
type: 'JOB_STARTED',
processUUID: 'uuid',
jobUUID: 'job',
})
vi.mocked(processStatus).mockResolvedValue(
type === 'COMPLETE'
? { type, processUUID: 'uuid', values: { message: 'Restarted 2 consumers.', count: 2 } }
: { type, error: 'Restart failed.' }
)
const user = userEvent.setup()
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
await user.click(screen.getByRole('button', { name: 'Restart Fulfill Order' }))
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeDisabled()
expect(toast.success).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
await waitFor(
() =>
expect(type === 'COMPLETE' ? toast.success : toast.error).toHaveBeenCalledWith(
type === 'COMPLETE' ? 'Restarted 2 consumers.' : 'Restart failed.'
),
{ timeout: 2500 }
)
expect(type === 'COMPLETE' ? toast.error : toast.success).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeEnabled()
}
)
it('treats a topic as having no broker queue', () => {
expect(destinationQueue(orderEsb.publications[0].destination)).toBeNull()
})
+42 -6
View File
@@ -78,7 +78,7 @@ export function deadLetterQueue(trigger: EsbTrigger): EsbQueueRef {
brokerQueueName: trigger.deadLetter.brokerName,
capabilities: trigger.destination.capabilities,
messageCount: trigger.deadLetter.messageCount,
paused: null,
paused: trigger.deadLetter.paused ?? null,
}
}
@@ -144,14 +144,19 @@ export function EsbTriggerActions({
* @returns A group of buttons.
*/
function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
const action = useEsbAction()
const [confirmReplay, setConfirmReplay] = useState(false)
const action = useEsbAction(() => setConfirmReplay(false))
const run = (processName: string, values: EsbActionValues = {}) =>
action.mutate({ processName, values: { triggerName: trigger.name, ...values } })
const paused = trigger.state === 'PAUSED'
const id = trigger.name
return (
<div className="flex flex-wrap gap-1" role="group" aria-label={`Actions for ${trigger.processLabel}`}>
<div
className="flex flex-wrap gap-1"
role="group"
aria-label={`Actions for ${trigger.processLabel}`}
>
{paused ? (
<ActionButton
label="Resume"
@@ -182,9 +187,25 @@ function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
ariaLabel={`Replay dead letters for ${trigger.processLabel}`}
qqqId={`button-esb-replay-dead-letters-${id}`}
disabled={action.isPending}
onClick={() => run('esbReplayDeadLetters', { all: true })}
onClick={() => setConfirmReplay(true)}
/>
)}
<EsbConfirmDialog
open={confirmReplay}
title="Replay dead letters"
confirmLabel="Replay dead letters"
destructive={false}
pending={action.isPending}
onCancel={() => setConfirmReplay(false)}
onConfirm={() => run('esbReplayDeadLetters', { all: true })}
>
Replay{' '}
{trigger.deadLetter.messageCount === null
? 'all dead letters (count unknown)'
: `all ${messageCountText(trigger.deadLetter.messageCount)}`}{' '}
from queue {trigger.deadLetter.brokerName} through {trigger.processLabel}? Successful
messages will be removed.
</EsbConfirmDialog>
</div>
)
}
@@ -196,14 +217,17 @@ function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
* @param props - Component props.
* @param props.queue - The broker queue.
* @param props.permissions - The current user's ESB permissions.
* @param props.onDone - Called after success to clear any browsed selection.
* @returns The buttons, or `null` when none is available.
*/
export function EsbQueueActions({
queue,
permissions,
onDone,
}: {
queue: EsbQueueRef
permissions: EsbPermissions
onDone?: () => void
}) {
const canPause = permissions.canOperate && queue.capabilities.pauseQueue && queue.paused !== null
const canPurge = permissions.canDelete && queue.capabilities.purge
@@ -215,6 +239,7 @@ export function EsbQueueActions({
canPause={canPause}
canPurge={canPurge}
canDeleteOlder={canDeleteOlder}
onDone={onDone}
/>
)
}
@@ -227,6 +252,7 @@ export function EsbQueueActions({
* @param props.canPause - Whether to offer pause or resume.
* @param props.canPurge - Whether to offer purge.
* @param props.canDeleteOlder - Whether to offer deleting messages older than a time.
* @param props.onDone - Called after an action succeeds.
* @returns A group of buttons.
*/
function QueueButtons({
@@ -234,11 +260,13 @@ function QueueButtons({
canPause,
canPurge,
canDeleteOlder,
onDone,
}: {
queue: EsbQueueRef
canPause: boolean
canPurge: boolean
canDeleteOlder: boolean
onDone?: () => void
}) {
const [dialog, setDialog] = useState<'purge' | 'deleteOlder' | null>(null)
const [olderThan, setOlderThan] = useState('')
@@ -247,7 +275,10 @@ function QueueButtons({
setDialog(null)
setOlderThan('')
}
const action = useEsbAction(close)
const action = useEsbAction(() => {
close()
onDone?.()
})
const target = { providerName: queue.providerName, brokerQueueName: queue.brokerQueueName }
const name = queue.brokerQueueName
const inQueue =
@@ -613,7 +644,12 @@ export function EsbConfirmDialog({
onCancel: () => void
}) {
return (
<AlertDialogPrimitive.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
<AlertDialogPrimitive.Root
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) onCancel()
}}
>
<AlertDialogPrimitive.Portal>
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<AlertDialogPrimitive.Content
+93 -5
View File
@@ -17,7 +17,7 @@
// Tests for browsing ESB messages and acting on selected ones
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
@@ -72,7 +72,7 @@ describe('EsbMessageList', () => {
})
})
it('lists a queue\'s messages with their event type and subject', async () => {
it("lists a queue's messages with their event type and subject", async () => {
renderWithClient(
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
)
@@ -80,12 +80,24 @@ describe('EsbMessageList', () => {
const rows = within(table).getAllByRole('row').slice(1)
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('ID:msg-1')
expect(rows[0]).toHaveTextContent('qqq.table.order.INSERT')
expect(rows[0]).toHaveTextContent('qqq.table.order.inserted')
expect(rows[0]).toHaveTextContent('1001')
expect(rows[1]).toHaveTextContent('ID:msg-2')
})
it('shows a dead letter\'s error and raw body when it is not a CloudEvent', async () => {
it('browses CloudEvents with the backend event type and source in both parsed and raw bodies', async () => {
renderWithClient(
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
)
const table = await screen.findByRole('table', { name: 'Messages' })
expect(table).toHaveTextContent('qqq.table.order.inserted')
for (const message of orderFulfillmentMessages.messages) {
expect(message.event?.source).toBe('qqq://demo/table/order')
expect(JSON.parse(message.rawBody)).toEqual(message.event)
}
})
it("shows a dead letter's error and raw body when it is not a CloudEvent", async () => {
renderWithClient(
<EsbMessageList
source={DEAD_LETTER_SOURCE}
@@ -166,7 +178,11 @@ describe('EsbMessageList', () => {
const dialog = await screen.findByRole('alertdialog')
const confirm = within(dialog).getByRole('button', { name: 'Move messages' })
expect(confirm).toBeDisabled()
await user.type(within(dialog).getByLabelText('Target queue'), 'orderFulfillment.hold')
await user.type(within(dialog).getByLabelText('Target queue'), ' ')
expect(confirm).toBeDisabled()
expect(processInit).not.toHaveBeenCalled()
await user.clear(within(dialog).getByLabelText('Target queue'))
await user.type(within(dialog).getByLabelText('Target queue'), ' orderFulfillment.hold ')
await user.click(confirm)
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith('esbMoveMessages', {
@@ -199,6 +215,78 @@ describe('EsbMessageList', () => {
)
})
it.each(['purge', 'deleteOlder'])('clears the selection after %s succeeds', async (operation) => {
const user = userEvent.setup()
renderWithClient(
<EsbMessageList
source={DEAD_LETTER_SOURCE}
queue={deadLetterQueue(fulfillTrigger)}
permissions={ALL}
/>
)
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' }))
await user.click(
screen.getByRole('button', {
name:
operation === 'purge'
? 'Purge orderFulfillment.dlq'
: 'Delete older messages in orderFulfillment.dlq',
})
)
const dialog = await screen.findByRole('alertdialog')
if (operation === 'deleteOlder') {
fireEvent.change(within(dialog).getByLabelText('Older than'), {
target: { value: '2026-09-25T10:00' },
})
}
await user.click(
within(dialog).getByRole('button', {
name: operation === 'purge' ? 'Purge queue' : 'Delete messages',
})
)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'Select ID:dlq-1' })).not.toBeChecked()
)
expect(screen.getByRole('button', { name: 'Replay selected' })).toBeDisabled()
})
it('offers no selection when move and delete are unsupported even with permissions', async () => {
renderWithClient(
<EsbMessageList
source={QUEUE_SOURCE}
queue={{
...fulfillmentQueue(),
capabilities: { ...fulfillmentQueue().capabilities, move: false, deleteSelected: false },
}}
permissions={ALL}
/>
)
await screen.findByRole('table', { name: 'Messages' })
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /selected/ })).not.toBeInTheDocument()
})
it('retains selection and confirmation after a failed purge', async () => {
vi.mocked(processInit).mockResolvedValue({ type: 'ERROR', error: 'Purge refused.' })
const user = userEvent.setup()
renderWithClient(
<EsbMessageList
source={DEAD_LETTER_SOURCE}
queue={deadLetterQueue(fulfillTrigger)}
permissions={ALL}
/>
)
const checkbox = await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' })
await user.click(checkbox)
await user.click(screen.getByRole('button', { name: 'Purge orderFulfillment.dlq' }))
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Purge queue' }))
await waitFor(() => expect(processInit).toHaveBeenCalled())
expect(dialog).toBeInTheDocument()
expect(checkbox).toBeChecked()
})
it('opens the list in a dialog from the browse button', async () => {
const user = userEvent.setup()
renderWithClient(
+19 -6
View File
@@ -41,6 +41,7 @@ import {
/** What to browse: the queue behind a destination, or a trigger's dead letters. */
export type EsbMessageSource =
| { kind: 'destination'; name: string }
| { kind: 'subscription'; name: string; triggerName: string }
| { kind: 'deadLetters'; triggerName: string }
/** Props for {@link EsbMessageList}. */
@@ -70,11 +71,19 @@ export function EsbMessageList({ source, queue, permissions }: EsbMessageListPro
const selectable = available.replay || available.move || available.delete
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.esbMessages(source.kind, name, offset),
queryKey:
source.kind === 'subscription'
? [...queryKeys.esbMessages('destination', source.name, offset), source.triggerName]
: queryKeys.esbMessages(source.kind, name, offset),
queryFn: () =>
source.kind === 'destination'
? getEsbMessages(source.name, offset)
: getEsbDeadLetters(source.triggerName, offset),
source.kind === 'deadLetters'
? getEsbDeadLetters(source.triggerName, offset)
: getEsbMessages(
source.name,
offset,
ESB_PAGE_SIZE,
source.kind === 'subscription' ? source.triggerName : undefined
),
meta: HANDLES_OWN_ERRORS,
})
@@ -84,12 +93,16 @@ export function EsbMessageList({ source, queue, permissions }: EsbMessageListPro
}
const toggle = (messageId: string) =>
setSelected((current) =>
current.includes(messageId) ? current.filter((id) => id !== messageId) : [...current, messageId]
current.includes(messageId)
? current.filter((id) => id !== messageId)
: [...current, messageId]
)
return (
<div className="space-y-3" data-qqq-id={`esb-message-list-${name}`}>
{source.kind === 'deadLetters' && <EsbQueueActions queue={queue} permissions={permissions} />}
{source.kind !== 'destination' && (
<EsbQueueActions queue={queue} permissions={permissions} onDone={() => setSelected([])} />
)}
{selectable && (
<EsbSelectionActions
queue={queue}
+217 -10
View File
@@ -17,12 +17,18 @@
// Tests for the table Developer-view ESB section
import React from 'react'
import { act, render, screen, within } from '@testing-library/react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { axe } from 'jest-axe'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { processInit } from '@/lib/api/processes'
import { fulfillOrderEsb, orderEsb } from '@/mocks/fixtures/esb'
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
import { fulfillOrderEsb, orderEsb, orderFulfillmentMessages } from '@/mocks/fixtures/esb'
import { EsbSection } from './EsbSection'
/**
@@ -47,6 +53,201 @@ function bodyRows(name: string): HTMLElement[] {
}
describe('EsbSection', () => {
beforeEach(() => {
vi.mocked(processInit).mockReset()
vi.mocked(processInit).mockResolvedValue({
type: 'COMPLETE',
processUUID: 'queue-run',
values: { message: 'Done.' },
})
})
it.each([
['subscription', false],
['subscription', true],
['subscription', null],
['subscription', undefined],
['deadLetter', false],
['deadLetter', true],
['deadLetter', null],
['deadLetter', undefined],
] as const)('uses %s paused=%s for broker queue controls', async (kind, paused) => {
const user = userEvent.setup()
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
const brokerName = 'serialized.custom.queue'
const brokerQueue = { brokerName, messageCount: 9, ...(paused === undefined ? {} : { paused }) }
const trigger = { ...original, [kind]: brokerQueue }
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<EsbSection
data={{
...orderEsb,
publications: [],
subscribers: [trigger],
permissions: { canOperate: true, canDelete: true },
}}
/>
</QueryClientProvider>
)
if (kind === 'deadLetter') {
await user.click(
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
)
await screen.findByRole('dialog')
}
if (paused == null) {
expect(
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
).not.toBeInTheDocument()
expect(processInit).not.toHaveBeenCalled()
} else {
const action = paused ? 'Resume' : 'Pause'
expect(
screen.queryByRole('button', { name: `${paused ? 'Pause' : 'Resume'} queue ${brokerName}` })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: `${action} queue ${brokerName}` }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith(paused ? 'esbResumeQueue' : 'esbPauseQueue', {
values: { providerName: 'artemis', brokerQueueName: brokerName },
})
)
}
})
it.each([
['subscription', 'permission'],
['subscription', 'capability'],
['deadLetter', 'permission'],
['deadLetter', 'capability'],
] as const)(
'hides %s pause/resume without %s even when state is known',
async (kind, missing) => {
const user = userEvent.setup()
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
const brokerName = 'restricted.queue'
const trigger = {
...original,
[kind]: { brokerName, messageCount: 9, paused: false },
destination: {
...original.destination,
capabilities: {
...original.destination.capabilities,
pauseQueue: missing !== 'capability',
},
},
}
render(
<QueryClientProvider client={new QueryClient()}>
<EsbSection
data={{
...orderEsb,
publications: [],
subscribers: [trigger],
permissions: { canOperate: missing !== 'permission', canDelete: true },
}}
/>
</QueryClientProvider>
)
if (kind === 'deadLetter') {
await user.click(
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
)
await screen.findByRole('dialog')
}
expect(
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
).not.toBeInTheDocument()
expect(processInit).not.toHaveBeenCalled()
}
)
it('uses the serialized subscription name for controls and trigger for browsing', async () => {
let requestedTrigger: string | null = null
server.use(
http.get('/qqq/v1/esb/messages/orderEvents', ({ request }) => {
requestedTrigger = new URL(request.url).searchParams.get('trigger')
return HttpResponse.json(orderFulfillmentMessages)
})
)
const user = userEvent.setup()
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<EsbSection
data={{
...orderEsb,
subscribers: [
{
...orderEsb.subscribers[1],
subscription: { brokerName: 'custom.subscription.queue', messageCount: 9 },
},
],
permissions: { canOperate: true, canDelete: true },
}}
/>
</QueryClientProvider>
)
expect(screen.getByText('custom.subscription.queue')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Purge custom.subscription.queue' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Pause queue custom.subscription.queue' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Browse subscription for Cancel Order' }))
const dialog = await screen.findByRole('dialog', {
name: 'Messages in custom.subscription.queue',
})
await within(dialog).findByRole('table', { name: 'Messages' })
await waitFor(() => expect(requestedTrigger).toBe('cancelOrder.orderEvents'))
expect(
within(dialog).queryByRole('button', { name: 'Replay selected' })
).not.toBeInTheDocument()
})
it('hides browsing and broker controls when the trigger destination has no capabilities', () => {
const trigger = orderEsb.subscribers[1]
render(
<QueryClientProvider client={new QueryClient()}>
<EsbSection
data={{
...orderEsb,
publications: [],
subscribers: [
{
...trigger,
destination: {
...trigger.destination,
capabilities: {
browse: false,
pauseQueue: false,
purge: false,
deleteSelected: false,
deleteOlderThan: false,
move: false,
},
},
},
],
permissions: { canOperate: true, canDelete: true },
}}
/>
</QueryClientProvider>
)
expect(screen.queryByRole('button', { name: /Browse/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Purge/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
})
it('renders nothing when the table has no ESB section', () => {
const { container } = render(<EsbSection data={null} />)
expect(container).toBeEmptyDOMElement()
@@ -88,7 +289,7 @@ describe('EsbSection', () => {
const fulfillCells = within(fulfill).getAllByRole('cell')
expect(within(fulfillCells[0]).getByRole('link', { name: 'Fulfill Order' })).toHaveAttribute(
'href',
'/app/fulfillOrder'
'/app/fulfillOrder/dev'
)
expect(fulfillCells[0]).toHaveTextContent('Single, concurrency 2, 3 attempts')
expect(fulfillCells[1]).toHaveTextContent('orderFulfillment')
@@ -107,7 +308,7 @@ describe('EsbSection', () => {
const cancelCells = within(cancel).getAllByRole('cell')
expect(within(cancelCells[0]).getByRole('link', { name: 'Cancel Order' })).toHaveAttribute(
'href',
'/app/cancelOrder'
'/app/cancelOrder/dev'
)
expect(cancelCells[1]).toHaveTextContent('orderEvents')
expect(cancelCells[2]).toHaveTextContent('Paused')
@@ -124,11 +325,13 @@ describe('EsbSection', () => {
).toBeInTheDocument()
})
it('offers browsing a queue and a trigger\'s dead letters to anyone who sees them', () => {
it("offers browsing a queue and a trigger's dead letters to anyone who sees them", () => {
render(<EsbSection data={orderEsb} />)
const [topic, queue] = bodyRows('Publications')
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
expect(within(queue).getByRole('button', { name: 'Browse orderFulfillment' })).toBeInTheDocument()
expect(
within(queue).getByRole('button', { name: 'Browse orderFulfillment' })
).toBeInTheDocument()
expect(within(queue).queryByRole('button', { name: /^Purge/ })).not.toBeInTheDocument()
const [fulfill] = bodyRows('Subscribers')
expect(
@@ -145,7 +348,9 @@ describe('EsbSection', () => {
)
const [topic, queue] = bodyRows('Publications')
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
expect(within(queue).getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
expect(
within(queue).getByRole('button', { name: 'Purge orderFulfillment' })
).toBeInTheDocument()
expect(
within(queue).getByRole('button', { name: 'Pause queue orderFulfillment' })
).toBeInTheDocument()
@@ -154,7 +359,7 @@ describe('EsbSection', () => {
expect(within(cancel).getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
})
it('renders a process\'s publications and the triggers that start it', () => {
it("renders a process's publications and the triggers that start it", () => {
render(<EsbSection data={fulfillOrderEsb} />)
expect(bodyRows('Publications')).toHaveLength(1)
expect(bodyRows('Triggers')).toHaveLength(1)
@@ -163,7 +368,9 @@ describe('EsbSection', () => {
it('says so when a process has no publications or triggers', () => {
render(<EsbSection data={{ ...fulfillOrderEsb, publications: [], triggers: [] }} />)
expect(screen.getByText('This process does not publish to any destination.')).toBeInTheDocument()
expect(
screen.getByText('This process does not publish to any destination.')
).toBeInTheDocument()
expect(screen.getByText('No destinations trigger this process.')).toBeInTheDocument()
})
+45 -11
View File
@@ -25,7 +25,7 @@ import Link from 'next/link'
import type { EsbPermissions, EsbTrigger, EsbTriggerMode, EsbTriggerState } from '@/types'
import { CHIP_COLOR_CLASSES, type ChipColor } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { EsbTriggerActions, deadLetterQueue } from './EsbActions'
import { EsbTriggerActions, EsbQueueActions, deadLetterQueue, type EsbQueueRef } from './EsbActions'
import { EsbCounters } from './EsbCounters'
import { EsbBrowseButton } from './EsbMessageList'
@@ -59,6 +59,15 @@ export function EsbTriggerRow({
trigger: EsbTrigger
permissions: EsbPermissions
}) {
const subscription: EsbQueueRef | null = trigger.subscription
? {
providerName: trigger.destination.provider,
brokerQueueName: trigger.subscription.brokerName,
capabilities: trigger.destination.capabilities,
messageCount: trigger.subscription.messageCount,
paused: trigger.subscription.paused ?? null,
}
: null
const state = STATES[trigger.state] ?? { label: trigger.state, color: 'default' }
const attempts = `${trigger.maxAttempts} ${trigger.maxAttempts === 1 ? 'attempt' : 'attempts'}`
@@ -69,7 +78,7 @@ export function EsbTriggerRow({
>
<td className="px-2 py-2">
<Link
href={`/app/${encodeURIComponent(trigger.processName)}`}
href={`/app/${encodeURIComponent(trigger.processName)}/dev`}
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
>
{trigger.processLabel}
@@ -78,7 +87,11 @@ export function EsbTriggerRow({
{MODES[trigger.mode] ?? trigger.mode}, concurrency {trigger.concurrency}, {attempts}
</p>
</td>
<td className="px-2 py-2 font-mono text-xs text-foreground">{trigger.destination.name}</td>
<td className="px-2 py-2 font-mono text-xs text-foreground">
{trigger.destination.name}
{subscription && <p className="text-muted-foreground">{subscription.brokerQueueName}</p>}
{subscription && <p>Subscription depth: {subscription.messageCount ?? '—'}</p>}
</td>
<td className="px-2 py-2">
<span
className={cn(
@@ -97,14 +110,35 @@ export function EsbTriggerRow({
</td>
<td className="space-y-1 px-2 py-2">
<EsbTriggerActions trigger={trigger} permissions={permissions} />
<EsbBrowseButton
label="Browse dead letters"
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
title={`Dead letters for ${trigger.processLabel}`}
source={{ kind: 'deadLetters', triggerName: trigger.name }}
queue={deadLetterQueue(trigger)}
permissions={permissions}
/>
{subscription && (
<>
{subscription.capabilities.browse && (
<EsbBrowseButton
label="Browse subscription"
ariaLabel={`Browse subscription for ${trigger.processLabel}`}
title={`Messages in ${subscription.brokerQueueName}`}
source={{
kind: 'subscription',
name: trigger.destination.name,
triggerName: trigger.name,
}}
queue={subscription}
permissions={permissions}
/>
)}
<EsbQueueActions queue={subscription} permissions={permissions} />
</>
)}
{trigger.destination.capabilities.browse && (
<EsbBrowseButton
label="Browse dead letters"
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
title={`Dead letters for ${trigger.processLabel}`}
source={{ kind: 'deadLetters', triggerName: trigger.name }}
queue={deadLetterQueue(trigger)}
permissions={permissions}
/>
)}
</td>
</tr>
)
+22 -1
View File
@@ -16,7 +16,7 @@
import React, { useState } from 'react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -111,6 +111,27 @@ describe('SearchDialog keyboard access', () => {
}
})
it('does not steal focus after the user reaches another dialog control before autofocus', async () => {
let autofocus: FrameRequestCallback | undefined
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
autofocus = callback
return 42
})
try {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
await user.type(screen.getByRole('combobox'), 'no matches')
await user.tab()
const clear = screen.getByRole('button', { name: 'Clear search' })
expect(clear).toHaveFocus()
act(() => autofocus?.(0))
expect(clear).toHaveFocus()
} finally {
request.mockRestore()
}
})
it('returns focus after Escape from the input and can reopen', async () => {
const user = userEvent.setup()
render(<Harness />)
+3 -1
View File
@@ -81,7 +81,9 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
setRecentRecords(getRecentRecords().slice(0, 10))
setSearchTerm('')
setSelectedIndex(-1)
const frame = requestAnimationFrame(() => inputRef.current?.focus())
const frame = requestAnimationFrame(() => {
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
})
return () => {
cancelAnimationFrame(frame)
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
@@ -88,6 +88,25 @@ beforeEach(() => {
mockFetchStandalone.mockResolvedValue(OPTIONS)
})
describe('PossibleValueSelect — pointer selection', () => {
it('keeps search focus while pressing an option until its click selects the value', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox', { name: 'Person' }))
const search = screen.getByRole('textbox', { name: 'Search Person options' })
await waitFor(() => expect(search).toHaveFocus())
const option = await screen.findByRole('option', { name: 'Bob' })
await user.pointer({ target: option, keys: '[MouseLeft>]' })
expect(search).toHaveFocus()
await user.pointer({ target: option, keys: '[/MouseLeft]' })
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('Bob')
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveFocus()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
describe('PossibleValueSelect — initial value label', () => {
it('looks up and shows the label of a value it did not select (e.g. after process Back)', async () => {
mockFetchProcess.mockResolvedValue([{ id: 2, label: 'Bob' }])
@@ -480,6 +480,9 @@ export function PossibleValueSelect({
id={`${id}-option-${index}`}
role="option"
aria-selected={isSelected}
// Keep search focus until click: WebKit can scroll on blur
// between mousedown and mouseup, moving the option away.
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(option)}
className={cn(
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
@@ -29,7 +29,11 @@ import { server } from '@/mocks/node'
import { EsbOverviewWidget } from './EsbOverviewWidget'
import { WidgetRenderer } from './WidgetRenderer'
const widgetMetaData = { name: 'esbOverview', label: 'ESB', type: 'ESB_OVERVIEW' } as QWidgetMetaData
const widgetMetaData = {
name: 'esbOverview',
label: 'ESB',
type: 'ESB_OVERVIEW',
} as QWidgetMetaData
/**
* Renders an element inside a fresh query client.
@@ -73,13 +77,16 @@ describe('EsbOverviewWidget', () => {
expect(bodyRows('Destinations')).toHaveLength(3)
expect(events).toHaveTextContent('orderEvents')
expect(events).toHaveTextContent('Topic')
expect(within(events).getByRole('link', { name: 'order' })).toHaveAttribute('href', '/app/order')
expect(within(events).getByRole('link', { name: 'order' })).toHaveAttribute(
'href',
'/app/order'
)
expect(events).toHaveTextContent('INSERT, UPDATE, DELETE')
expect(fulfillment).toHaveTextContent('Queue')
expect(within(fulfillment).getAllByRole('cell')[3]).toHaveTextContent(/^7$/)
expect(within(results).getByRole('link', { name: 'fulfillOrder' })).toHaveAttribute(
'href',
'/app/fulfillOrder'
'/app/fulfillOrder/dev'
)
})
@@ -105,7 +112,9 @@ describe('EsbOverviewWidget', () => {
)
renderWithClient(<EsbOverviewWidget widgetMetaData={widgetMetaData} />)
await screen.findByRole('table', { name: 'Triggers' })
expect(screen.queryByRole('button', { name: /^(Pause|Resume|Restart|Purge)/ })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^(Pause|Resume|Restart|Purge)/ })
).not.toBeInTheDocument()
})
it('says so when the ESB status is not available', async () => {
@@ -119,7 +128,9 @@ describe('EsbOverviewWidget', () => {
})
it('is rendered for the ESB_OVERVIEW widget type', async () => {
renderWithClient(<WidgetRenderer widgetMetaData={widgetMetaData} data={{ type: 'ESB_OVERVIEW' }} />)
renderWithClient(
<WidgetRenderer widgetMetaData={widgetMetaData} data={{ type: 'ESB_OVERVIEW' }} />
)
expect(await screen.findByRole('table', { name: 'Destinations' })).toBeInTheDocument()
})
+15 -4
View File
@@ -25,7 +25,12 @@ import React from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import type { EsbOverviewDestination, EsbPermissions, EsbProviderType, QWidgetMetaData } from '@/types'
import type {
EsbOverviewDestination,
EsbPermissions,
EsbProviderType,
QWidgetMetaData,
} from '@/types'
import { getEsbOverview } from '@/lib/api/esb'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { CHIP_COLOR_CLASSES } from '@/lib/utils/adornment-utils'
@@ -126,7 +131,11 @@ export function EsbOverviewWidget({ widgetMetaData }: { widgetMetaData: QWidgetM
))}
</EsbList>
<EsbList title="Triggers" columns={TRIGGER_COLUMNS} empty="No triggers on processes you can access.">
<EsbList
title="Triggers"
columns={TRIGGER_COLUMNS}
empty="No triggers on processes you can access."
>
{triggers.map((trigger) => (
<EsbTriggerRow key={trigger.name} trigger={trigger} permissions={data.permissions} />
))}
@@ -174,12 +183,14 @@ function DestinationRow({
{publisher.kind === 'TABLE' ? 'Table' : 'Process'}{' '}
</span>
<Link
href={`/app/${encodeURIComponent(publisher.name)}`}
href={`/app/${encodeURIComponent(publisher.name)}${publisher.kind === 'PROCESS' ? '/dev' : ''}`}
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
>
{publisher.name}
</Link>
<span className="text-xs text-muted-foreground">: {publisher.events.join(', ')}</span>
<span className="text-xs text-muted-foreground">
: {publisher.events.join(', ')}
</span>
</li>
))}
</ul>
+111 -10
View File
@@ -27,7 +27,7 @@ import {
orderFulfillmentMessages,
} from '@/mocks/fixtures/esb'
import { server } from '@/mocks/node'
import { processInit } from './processes'
import { processInit, processStatus } from './processes'
import {
getEsbDeadLetters,
getEsbForProcess,
@@ -37,7 +37,7 @@ import {
runEsbAction,
} from './esb'
vi.mock('./processes', () => ({ processInit: vi.fn() }))
vi.mock('./processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
describe('getEsbForTable', () => {
it('returns the table ESB section from GET /qqq/v1/esb/table/{table}', async () => {
@@ -166,13 +166,15 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
return HttpResponse.json(orderFulfillmentMessages)
})
)
await expect(getEsbMessages('orderFulfillment', 50, 25)).resolves.toEqual(orderFulfillmentMessages)
await expect(getEsbMessages('orderFulfillment', 50, 25)).resolves.toEqual(
orderFulfillmentMessages
)
expect(url.pathname).toBe('/qqq/v1/esb/messages/orderFulfillment')
expect(url.searchParams.get('offset')).toBe('50')
expect(url.searchParams.get('limit')).toBe('25')
})
it('browse a trigger\'s dead letters, 50 at a time from the start by default', async () => {
it("browse a trigger's dead letters, 50 at a time from the start by default", async () => {
let url = new URL('http://localhost')
server.use(
http.get('/qqq/v1/esb/deadLetters/:trigger', ({ request }) => {
@@ -188,6 +190,33 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
expect(url.searchParams.get('limit')).toBe('50')
})
it('browses the named topic subscription with the backend trigger parameter', async () => {
let url = new URL('http://localhost')
server.use(
http.get('/qqq/v1/esb/messages/:destination', ({ request }) => {
url = new URL(request.url)
return HttpResponse.json(orderFulfillmentMessages)
})
)
await expect(getEsbMessages('orderEvents', 50, 25, 'cancelOrder.orderEvents')).resolves.toEqual(
orderFulfillmentMessages
)
expect(url.pathname).toBe('/qqq/v1/esb/messages/orderEvents')
expect(url.searchParams.get('trigger')).toBe('cancelOrder.orderEvents')
expect(url.searchParams.get('offset')).toBe('50')
expect(url.searchParams.get('limit')).toBe('25')
})
it('serves the demo topic subscription and rejects missing or unrelated triggers', async () => {
await expect(getEsbMessages('orderEvents', 0, 50, 'cancelOrder.orderEvents')).resolves.toEqual(
orderFulfillmentMessages
)
await expect(getEsbMessages('orderEvents')).rejects.toThrow()
await expect(
getEsbMessages('orderEvents', 0, 50, 'fulfillOrder.orderFulfillment')
).rejects.toThrow()
})
it('rejects a body that is not a message page', async () => {
server.use(http.get('/qqq/v1/esb/messages/orderFulfillment', () => HttpResponse.json({})))
await expect(getEsbMessages('orderFulfillment')).rejects.toThrow('Invalid ESB message page')
@@ -206,6 +235,7 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
describe('runEsbAction', () => {
beforeEach(() => {
vi.mocked(processInit).mockReset()
vi.mocked(processStatus).mockReset()
})
it('runs the operate process with the values and returns its count and message', async () => {
@@ -215,7 +245,10 @@ describe('runEsbAction', () => {
values: { count: 7, message: 'Purged 7 messages.' },
})
await expect(
runEsbAction('esbPurgeQueue', { providerName: 'artemis', brokerQueueName: 'orderFulfillment' })
runEsbAction('esbPurgeQueue', {
providerName: 'artemis',
brokerQueueName: 'orderFulfillment',
})
).resolves.toEqual({ count: 7, message: 'Purged 7 messages.' })
expect(processInit).toHaveBeenCalledWith('esbPurgeQueue', {
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
@@ -243,11 +276,79 @@ describe('runEsbAction', () => {
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('The broker refused the purge.')
})
it('says the action is still running when it continues as a job', async () => {
vi.mocked(processInit).mockResolvedValue({ type: 'JOB_STARTED', processUUID: 'uuid', jobUUID: 'job' })
await expect(runEsbAction('esbRestartTrigger', {})).resolves.toEqual({
count: null,
message: 'The action is still running.',
it.each(['process error', 'request failure'])(
'rejects an async %s instead of reporting success',
async (failure) => {
vi.useFakeTimers()
try {
vi.mocked(processInit).mockResolvedValue({
type: 'JOB_STARTED',
processUUID: 'uuid',
jobUUID: 'job',
})
if (failure === 'process error') {
vi.mocked(processStatus).mockResolvedValue({
type: 'ERROR',
error: 'Technical failure',
userFacingError: 'Replay failed; messages remain.',
})
} else {
vi.mocked(processStatus).mockRejectedValue(new Error('Status unavailable'))
}
const assertion = expect(
runEsbAction('esbReplayDeadLetters', { all: true })
).rejects.toThrow(
failure === 'process error' ? 'Replay failed; messages remain.' : 'Status unavailable'
)
await vi.runAllTimersAsync()
await assertion
} finally {
vi.useRealTimers()
}
}
)
it('does not report success for a running response with no job identifier', async () => {
vi.mocked(processInit).mockResolvedValue({ type: 'RUNNING', processUUID: 'uuid' })
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('no job identifier')
})
it('does not report a completed intermediate step as the final action outcome', async () => {
vi.mocked(processInit).mockResolvedValue({
type: 'COMPLETE',
processUUID: 'uuid',
nextStep: 'confirm',
values: {},
})
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('additional step')
})
it('waits for the final outcome through running job statuses', async () => {
vi.useFakeTimers()
try {
vi.mocked(processInit).mockResolvedValue({
type: 'JOB_STARTED',
processUUID: 'uuid',
jobUUID: 'job',
})
vi.mocked(processStatus)
.mockResolvedValueOnce({ type: 'RUNNING', processUUID: 'uuid' })
.mockResolvedValueOnce({
type: 'COMPLETE',
processUUID: 'uuid',
values: { count: 3, message: 'Replayed 3 messages.' },
})
const settled = vi.fn()
const result = runEsbAction('esbReplayDeadLetters', { all: true }).then(settled)
await Promise.resolve()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
await vi.runAllTimersAsync()
await result
expect(processStatus).toHaveBeenCalledWith('esbReplayDeadLetters', 'uuid', 'job')
expect(settled).toHaveBeenCalledWith({ count: 3, message: 'Replayed 3 messages.' })
} finally {
vi.useRealTimers()
}
})
})
+31 -9
View File
@@ -26,7 +26,7 @@ import type {
} from '@/types'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient from './client'
import { processInit } from './processes'
import { processInit, processStatus } from './processes'
/** Messages per page when browsing, the backend's default. */
export const ESB_PAGE_SIZE = 50
@@ -87,15 +87,22 @@ export async function getEsbOverview(): Promise<EsbOverviewResponse | null> {
* @param destinationName - QQQ destination name.
* @param offset - Messages to skip.
* @param limit - Messages to return.
* @param triggerName - Required for a topic: the trigger whose subscription to browse.
* @returns The page of messages.
* @throws The request error, or an error when the body is not a message page.
*/
export async function getEsbMessages(
destinationName: string,
offset = 0,
limit = ESB_PAGE_SIZE
limit = ESB_PAGE_SIZE,
triggerName?: string
): Promise<EsbMessagePage> {
return getMessagePage(`/esb/messages/${encodeURIComponent(destinationName)}`, offset, limit)
return getMessagePage(
`/esb/messages/${encodeURIComponent(destinationName)}`,
offset,
limit,
triggerName
)
}
/**
@@ -120,19 +127,26 @@ export async function getEsbDeadLetters(
*
* @param processName - The operate process, e.g. `esbPurgeQueue`.
* @param values - Its input values, e.g. `providerName` and `brokerQueueName`.
* @returns The process's `count` and `message`; a generic message while it continues as a job.
* @returns The completed process's `count` and `message`.
* @throws An error with the process's user-facing message when it fails or is refused.
*/
export async function runEsbAction(
processName: string,
values: Record<string, string | boolean>
): Promise<EsbActionResult> {
const response = await processInit(processName, { values })
let response = await processInit(processName, { values })
let jobUUID: string | undefined
while (response.type === 'JOB_STARTED' || response.type === 'RUNNING') {
if (response.type === 'JOB_STARTED') jobUUID = response.jobUUID
if (!jobUUID) throw new Error('The action returned no job identifier to check its outcome.')
await new Promise((resolve) => setTimeout(resolve, 1000))
response = await processStatus(processName, response.processUUID, jobUUID)
}
if (response.type === 'ERROR') {
throw new Error(response.userFacingError ?? response.error)
}
if (response.type !== 'COMPLETE') {
return { count: null, message: 'The action is still running.' }
if (response.nextStep) {
throw new Error('The action requires an unexpected additional step.')
}
const count = Number(response.values.count)
const message = response.values.message
@@ -165,11 +179,19 @@ async function getOrNull(path: string): Promise<unknown> {
* @param path - Path under the API base URL.
* @param offset - Messages to skip.
* @param limit - Messages to return.
* @param triggerName - Optional topic trigger name.
* @returns The page.
* @throws The request error, or an error when the body is not a message page.
*/
async function getMessagePage(path: string, offset: number, limit: number): Promise<EsbMessagePage> {
const data = await apiClient.get<unknown>(path, { params: { offset, limit } })
async function getMessagePage(
path: string,
offset: number,
limit: number,
triggerName?: string
): Promise<EsbMessagePage> {
const data = await apiClient.get<unknown>(path, {
params: { offset, limit, ...(triggerName ? { trigger: triggerName } : {}) },
})
if (!hasArrays<EsbMessagePage>(data, 'messages')) {
throw new Error('Invalid ESB message page')
}
@@ -30,9 +30,10 @@ vi.mock('@/lib/api/auth', () => ({
clearAuthMetadataCache: vi.fn(),
}))
import { createPasswordSession, logout, manageSession, resumeSession } from '@/lib/api/auth'
import { createPasswordSession, getAuthenticationMetaData, logout, manageSession, resumeSession } from '@/lib/api/auth'
import { AuthProvider } from './auth-provider'
import { useAuth } from './use-auth'
import * as oidc from './oidc'
import { storeUser, getStoredUser, hasSessionHint, setSessionHint } from './auth-storage'
let auth: ReturnType<typeof useAuth> | null = null
@@ -102,6 +103,62 @@ describe('AuthProvider with TABLE_BASED authentication', () => {
write.mockRestore()
})
it.each([false, true])('keeps authentication loading until logout settles (failure: %s)', async (fails) => {
let finishLogout!: () => void
vi.mocked(logout).mockReturnValue(new Promise<void>((resolve, reject) => {
finishLogout = () => fails ? reject(new Error('Logout unavailable')) : resolve()
}))
vi.mocked(createPasswordSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('signed out: no error')
await act(async () => { await auth?.signInWithPassword('tess.table', 'table:pass-2026') })
let pendingLogout: Promise<void> | undefined
await act(async () => { pendingLogout = auth?.logout() })
expect(screen.getByText('loading')).toBeInTheDocument()
expect(auth?.isAuthenticated).toBe(false)
expect(auth?.user).toBeNull()
await act(async () => { finishLogout(); await pendingLogout })
expect(screen.getByText('signed out: no error')).toBeInTheDocument()
expect(auth?.isLoading).toBe(false)
})
it('leaves loading when provider logout falls back to the local login page', async () => {
vi.mocked(getAuthenticationMetaData).mockResolvedValueOnce({ name: 'oauth', type: 'OAUTH2' })
vi.mocked(resumeSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
vi.mocked(logout).mockResolvedValue(undefined)
setSessionHint(true)
const endSession = vi.spyOn(oidc, 'buildEndSessionUrl').mockRejectedValue(new Error('Provider unavailable'))
try {
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('Tess Table <tess.table>')
await act(async () => { await auth?.logout() })
expect(endSession).toHaveBeenCalled()
expect(screen.getByText('signed out: no error')).toBeInTheDocument()
expect(auth?.isLoading).toBe(false)
} finally {
endSession.mockRestore()
}
})
it('keeps loading after provider logout starts an external navigation', async () => {
vi.mocked(getAuthenticationMetaData).mockResolvedValueOnce({ name: 'oauth', type: 'OAUTH2' })
vi.mocked(resumeSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
vi.mocked(logout).mockResolvedValue(undefined)
setSessionHint(true)
const endSession = vi.spyOn(oidc, 'buildEndSessionUrl').mockResolvedValue('https://idp.example/logout')
try {
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('Tess Table <tess.table>')
// jsdom leaves the document in place when location.assign starts navigation.
await act(async () => { await auth?.logout() })
expect(endSession).toHaveBeenCalled()
expect(screen.getByText('loading')).toBeInTheDocument()
expect(auth?.isLoading).toBe(true)
} finally {
endSession.mockRestore()
}
})
it('reports refused credentials and stays signed out', async () => {
vi.mocked(createPasswordSession).mockRejectedValue(refused('Incorrect username or password.'))
render(<AuthProvider><Probe /></AuthProvider>)
+49 -41
View File
@@ -102,7 +102,7 @@ export interface AuthUser {
export interface AuthContextType {
/** Whether the current user has an active, validated session. */
isAuthenticated: boolean
/** Whether an auth check or sign-in is in flight. */
/** True while checking authentication, signing in or signing out. */
isLoading: boolean
/** The authenticated user, or `null` while unauthenticated. */
user: AuthUser | null
@@ -385,50 +385,58 @@ export function AuthProvider({ children, onAuthError }: AuthProviderProps) {
}, [applySession])
const handleLogout = useCallback(async () => {
const metadata = authMetadata
const loginPath = `/login?returnTo=${encodeURIComponent(currentReturnTo())}`
const providerLogout = Boolean(metadata && PROVIDER_TYPES.has(metadata.type))
// Signed out first: protected pages unmount (no refetch with an ending session) and the
// login page shows the signed-out state instead of signing in again. Provider logouts
// leave the page for the identity provider instead, so no in-app navigation is started.
redirectingToLogin.current = true
setSignedOut(true)
setSessionHint(false)
if (!providerLogout) {
// Do not offer another sign-in until the old response has expired its cookies (#984).
let leavingForProvider = false
setIsLoading(true)
try {
const metadata = authMetadata
const loginPath = `/login?returnTo=${encodeURIComponent(currentReturnTo())}`
const providerLogout = Boolean(metadata && PROVIDER_TYPES.has(metadata.type))
// Signed out first: protected pages unmount (no refetch with an ending session) and the
// login page shows the signed-out state instead of signing in again. Provider logouts
// leave the page for the identity provider instead, so no in-app navigation is started.
redirectingToLogin.current = true
setSignedOut(true)
setSessionHint(false)
if (!providerLogout) {
setSignedOutState(true)
setIsAuthenticated(false)
setUser(null)
}
try {
// the backend also expires both session cookies (QRun-IO/qqq#674)
await apiLogout()
} catch (error) {
// The local sign-out still happens; the backend session is left to expire, and the
// next sign-in replaces its cookies (QRun-IO/qqq#733).
console.warn('[Auth] Backend logout failed:', error instanceof Error ? error.message : error)
}
resetReauthAttempts()
clearUserClientData()
// Forget the analytics identity with the session (Material resets its providers on logout)
analytics.reset()
clearAuthMetadataCache()
queryClient.clear()
if (metadata && PROVIDER_TYPES.has(metadata.type)) {
try {
const endSession = await buildEndSessionUrl(metadata, `${window.location.origin}/login`)
if (endSession) {
window.location.assign(endSession)
leavingForProvider = true
return
}
} catch (error) {
console.warn('[Auth] Identity provider sign-out unavailable:', error instanceof Error ? error.message : error)
}
}
setSignedOutState(true)
setIsAuthenticated(false)
setUser(null)
router.push(loginPath)
} finally {
if (!leavingForProvider) setIsLoading(false)
}
try {
// the backend also expires both session cookies (QRun-IO/qqq#674)
await apiLogout()
} catch (error) {
// The local sign-out still happens; the backend session is left to expire, and the
// next sign-in replaces its cookies (QRun-IO/qqq#733).
console.warn('[Auth] Backend logout failed:', error instanceof Error ? error.message : error)
}
resetReauthAttempts()
clearUserClientData()
// Forget the analytics identity with the session (Material resets its providers on logout)
analytics.reset()
clearAuthMetadataCache()
queryClient.clear()
if (metadata && PROVIDER_TYPES.has(metadata.type)) {
try {
const endSession = await buildEndSessionUrl(metadata, `${window.location.origin}/login`)
if (endSession) {
window.location.assign(endSession)
return
}
} catch (error) {
console.warn('[Auth] Identity provider sign-out unavailable:', error instanceof Error ? error.message : error)
}
}
setSignedOutState(true)
setIsAuthenticated(false)
setUser(null)
router.push(loginPath)
}, [authMetadata, router])
const handleOAuthCallback = useCallback(async (code: string, state: string, codeVerifier?: string) => {
+14 -7
View File
@@ -107,6 +107,7 @@ const fulfillOrderTrigger: EsbTrigger = {
lastError: 'Warehouse API timed out',
}),
deadLetter: { brokerName: 'orderFulfillment.dlq', messageCount: 1 },
subscription: null,
}
const cancelOrderTrigger: EsbTrigger = {
@@ -126,6 +127,7 @@ const cancelOrderTrigger: EsbTrigger = {
maxMs: 80,
}),
deadLetter: { brokerName: 'orderEvents.cancelOrder.orderEvents.dlq', messageCount: null },
subscription: { brokerName: 'cancelOrder.orderEvents', messageCount: null },
}
const fulfillmentResults: EsbDestination = {
@@ -189,11 +191,12 @@ export const orderFulfillmentMessages: EsbMessagePage = {
event: {
specversion: '1.0',
id: 'evt-1',
source: 'qqq/table/order',
type: 'qqq.table.order.INSERT',
source: 'qqq://demo/table/order',
type: 'qqq.table.order.inserted',
subject: '1001',
},
rawBody: '{"specversion":"1.0","id":"evt-1","type":"qqq.table.order.INSERT","subject":"1001"}',
rawBody:
'{"specversion":"1.0","id":"evt-1","source":"qqq://demo/table/order","type":"qqq.table.order.inserted","subject":"1001"}',
properties: {},
},
{
@@ -203,11 +206,12 @@ export const orderFulfillmentMessages: EsbMessagePage = {
event: {
specversion: '1.0',
id: 'evt-2',
source: 'qqq/table/order',
type: 'qqq.table.order.INSERT',
source: 'qqq://demo/table/order',
type: 'qqq.table.order.inserted',
subject: '1002',
},
rawBody: '{"specversion":"1.0","id":"evt-2","type":"qqq.table.order.INSERT","subject":"1002"}',
rawBody:
'{"specversion":"1.0","id":"evt-2","source":"qqq://demo/table/order","type":"qqq.table.order.inserted","subject":"1002"}',
properties: {},
},
],
@@ -222,7 +226,10 @@ export const fulfillOrderDeadLetters: EsbMessagePage = {
deliveryCount: 3,
event: null,
rawBody: 'not json',
properties: { qqqError: 'unparseable message', qqqFailedTrigger: 'fulfillOrder.orderFulfillment' },
properties: {
qqqError: 'unparseable message',
qqqFailedTrigger: 'fulfillOrder.orderFulfillment',
},
},
],
hasMore: false,
+12 -3
View File
@@ -71,9 +71,18 @@ export const esbHandlers = [
esbResponse(esbProcesses, String(params.processName), 'Process')
),
http.get(`${BASE}/esb/overview`, () => HttpResponse.json(esbOverview)),
http.get(`${BASE}/esb/messages/:destination`, ({ params }) =>
esbResponse(esbMessages, String(params.destination), 'Destination')
),
http.get(`${BASE}/esb/messages/:destination`, ({ params, request }) => {
if (params.destination === 'orderEvents') {
const trigger = new URL(request.url).searchParams.get('trigger')
if (!trigger)
return HttpResponse.json({ error: 'A topic requires a trigger.' }, { status: 400 })
if (trigger !== 'cancelOrder.orderEvents') {
return HttpResponse.json({ error: 'Trigger not found on orderEvents.' }, { status: 404 })
}
return HttpResponse.json(orderFulfillmentMessages)
}
return esbResponse(esbMessages, String(params.destination), 'Destination')
}),
http.get(`${BASE}/esb/deadLetters/:trigger`, ({ params }) =>
esbResponse(esbDeadLetters, String(params.trigger), 'Trigger')
),
+4
View File
@@ -79,6 +79,8 @@ export type EsbTriggerState = 'RUNNING' | 'PAUSED' | 'CONNECTING' | 'STOPPED'
/** A trigger's dead-letter queue. */
export interface EsbDeadLetter {
brokerName: string
/** Broker delivery state; absent on older backends, null without management data. */
paused?: boolean | null
/** Messages waiting in the dead-letter queue, or `null` when the broker cannot report it. */
messageCount: number | null
}
@@ -96,6 +98,8 @@ export interface EsbTrigger {
state: EsbTriggerState
counters: EsbCounter
deadLetter: EsbDeadLetter
/** Broker queue for a topic subscription; null for queue triggers. */
subscription: EsbDeadLetter | null
}
/** What the current user may do with the ESB objects in a response. */
+59 -3
View File
@@ -39,7 +39,14 @@ QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build
Set `QQQ_MIDDLEWARE_API_JAR` to another path when Maven uses a different local repository.
The fixture registers one API-aware v1 version so WID-072 uses real metadata, query and count routes.
- **Ports.** Set them with `QQQ_ACCEPTANCE_BACKEND_PORT` (default 18765) and
`QQQ_ACCEPTANCE_FRONTEND_PORT` (default 13765). Use distinct ports for concurrent runs.
`QQQ_ACCEPTANCE_FRONTEND_PORT` (default 13765). The sample's embedded Artemis broker uses
`QQQ_ACCEPTANCE_ESB_PORT` (default 61616, passed as `qqq.sample.esb.port`). Use distinct
HTTP and broker ports for concurrent runs. The security variant uses
`QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT` (default backend HTTP port + 10),
`QQQ_ACCEPTANCE_SECURITY_ESB_PORT` (default main ESB port + 1, normally 61617),
and `QQQ_ACCEPTANCE_SECURITY_IDP_PORT` (default frontend HTTP port + 10).
Its broker setting is passed as `qqq.sample.esb.port`; explicit variant
`options.properties` retain precedence over these defaults.
- **Results.** Output lands in `test-results/acceptance/`: `report.json`, `gate.json` (with
per-project counts in `byProject`), the HTML report, and a trace, video and screenshot for
each failure.
@@ -76,7 +83,9 @@ there. Skipping them with `test.skip` is not allowed: skips fail the gate.
- **Use `test` and `expect` from `support/fixtures`.** Each test gets a freshly reset
database, its own mock session, and a persona (`test.use({ persona: 'viewer' })`).
- Personas are `admin`, `viewer` (reads only, no processes), `noPets`, `noProcesses`,
`noApps` and `expired` (401 with the cookie cleared). The sample sharing demo is enabled;
`noApps` and `expired` (401 with the cookie cleared). ESB personas are `noPersonRead`
(all permissions except `person.read`), `noEsbView`, `noEsbOperate`, `noEsbDelete`,
and `noSyncPerson` (each removes only its named access permission). The sample sharing demo is enabled;
choose its identity with `test.use({ user: 'bob' })` (alice by default).
- `backend.sql(select)` reads the owned H2 database directly. Use it to verify persisted
values independently of the UI and API.
@@ -93,11 +102,18 @@ there. Skipping them with `test.skip` is not allowed: skips fail the gate.
a navigation cancels are not failures: `ERR_ABORTED`/`NS_BINDING_ABORTED`/cancelled, and
WebKit's "… due to access control checks." for a same-origin Next.js route prefetch or
RSC payload reported within a second of a document navigation (listed under
`interruptedFetches` in the attached `diagnostics.json`).
`interruptedFetches` in the attached `diagnostics.json`). Firefox legacy-font diagnostics
are classified there only when the exact cancellation status matches a same-origin font
request from an earlier document near navigation (aborted, or HTTP 200 before decode was cancelled), and the final document successfully
loads and decodes that font. Every unmatched or failed-decode diagnostic still fails.
- **v1 only.** The `diagnostics` fixture also fails a test whose page calls an unversioned API
route of a QQQ server (`/data`, `/processes`, `/widget`, `/possibleValues`, `/download`,
`/reports`, `/metaData`, `/manageSession`, `/logout` outside `/qqq/v1`; QRun-IO/qqq#699).
`allow()` does not waive it. Node-side `backend.api` calls may still exercise legacy routes.
- **Transient feedback.** Arm `expectToastDuringAction` before the triggering action when
asserting a toast. It observes the visible exact text and notification count inside the
browser (plus header clearance when requested), so driver/trace delays cannot outlast the
notification before observation begins. Assert persistent form and database state afterwards.
- **Assert real behavior.** Check exact values, labels, counts and persisted rows. A 200
response or a visible container is not acceptance.
- **Fixtures.** Each area owns `fixture/<Area>Fixtures.java`: `define()` adds metadata;
@@ -106,3 +122,43 @@ there. Skipping them with `test.skip` is not allowed: skips fail the gate.
- **Matrix.** Each area owns `matrix/<area>.json` and its ID prefixes. A row has
`id, feature, source[], fixture, scenarios[], negative[], issues[], required`.
`docs/acceptance/feature-matrix.md` is generated from these files.
## Pinned Linux WebKit crash fix
The test runner is temporarily pinned to `@playwright/test@1.64.0-alpha-2026-10-01`: its WebKit 2369 bundle contains libsoup 3.6.6. The previous 1.58.2/WebKit 2248 bundle contains 3.6.5 and reproduced heap corruption during navigation; the same browser using the fixed library passed 110 targeted cases, and the official replacement passed 72 repeated WebKit/tablet cases against the native Linux fixture. The 110-case comparison also corrected an unrelated iframe forwarding problem; the reproduced heap crash occurred outside that iframe workflow. See [QQQ#904](https://github.com/QRun-IO/qqq/issues/904) and [upstream#42803](https://github.com/microsoft/playwright/issues/42803). This is test infrastructure, not a browser shipped with Next UI. Replace the dated pin with a stable release containing the fix after running the acceptance gate ([QQQ#973](https://github.com/QRun-IO/qqq/issues/973)); do not downgrade to the affected stable 1.63 merely to remove the prerelease suffix.
## ESB (QRun-IO/qqq#986)
The sample JAR must include its embedded Artemis broker, `personEvents` topic,
`syncPerson` subscriber, ESB app/widget, all nine management processes, and the ESB
module's real v1 routes. `EsbFixtures` requires that production wiring and fails startup
if it is absent; it never registers replacements. Its application step runs the sample subscriber, then writes an owned H2
receipt of the processed event; the failure control makes that step fail to exercise
real dead-lettering and replay. Production API responses and broker operations are
never mocked. All control routes remain on the loopback-only acceptance server.
Before each test, reset stops consumers, verifies they stopped, resumes then drains the owned
subscription and dead-letter queue, closes pooled connections, clears counters and
reseeds H2, then waits for both the subscriber and control channel to be ready.
Teardown stops and drains even when the test fails. The readiness route returns 200
only after initial fixture reset completes. The sample owns broker startup/shutdown.
The pause scenario reads actual Artemis consumer, delivery and message counts through
an owned support route: a PAUSED runner can still finish its outstanding receive,
so publishing waits until its broker consumer has closed. No fixed sleep or client
prefetch override is used.
```bash
# After building the sample JAR and production export; set QQQ_SAMPLE_JAR and
# QQQ_MIDDLEWARE_API_JAR as described above. Reserve both ports before starting.
QQQ_ACCEPTANCE_BACKEND_PORT=18961 QQQ_ACCEPTANCE_ESB_PORT=18962 \
QQQ_ACCEPTANCE_BROWSERS=chromium \
node scripts/acceptance.mjs --skip-build specs/esb --max-failures=1
```
`ESB-001`–`ESB-010` are required matrix rows. The sample has no management URL, so
these tests verify real trigger pause/resume/restart, topic-subscription browsing,
dead-letter replay, application/process/table permissions and unsupported-capability
hiding. Replay confirmation explicitly reports the unavailable broker count.
Successful broker-level purge/delete/move/pause and a numeric dead-letter count need
a broker management endpoint; this fixture does not claim to prove those operations.
The ESB table-probe blanket 404 diagnostic exemption is removed: unexpected failed
requests fail acceptance, and negative cases allow only their specific denial.
@@ -64,6 +64,7 @@ public class AcceptanceSampleServer
private static final Map<String, String> USERS = new ConcurrentHashMap<>();
private static final ThreadLocal<String> REQUEST_SESSION_COOKIE = new ThreadLocal<>();
private static volatile QInstance instance;
private static volatile boolean ready;
@@ -108,6 +109,7 @@ public class AcceptanceSampleServer
QInstance defined = super.defineQInstance();
defined.setDefaultPermissionRules(QPermissionRules.defaultInstance().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
defined.getAuthentication().setCustomizer(new QCodeReference(PersonaCustomizer.class));
EsbFixtures.define(defined);
NavigationFixtures.define(defined);
RecordsFixtures.define(defined);
FormAdjusterFixtures.define(defined);
@@ -126,7 +128,17 @@ public class AcceptanceSampleServer
server.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "127.0.0.1";
config.routes.get("/acceptance/ready", context -> context.result("ready"));
config.routes.get("/acceptance/ready", context -> context.status(ready ? 200 : 503).result(ready ? "ready" : "starting"));
config.routes.post("/acceptance/esb/failure", context ->
{
EsbFixtures.setFailure(new JSONObject(context.body()).getBoolean("fail"));
context.contentType("application/json").result("{}");
});
config.routes.post("/acceptance/esb/stop", context ->
{
EsbFixtures.stopAndDrain(instance);
context.contentType("application/json").result("{}");
});
//////////////////////////////////////////////////////////////////////////////
// manageSession creates a new mock session; keep the test's persona and user //
// by resolving them from the request's sessionId cookie for that request. //
@@ -160,6 +172,10 @@ public class AcceptanceSampleServer
}
context.contentType("application/json").result(new JSONObject().put("rows", select(query)).toString());
});
config.routes.post("/acceptance/esb/subscription-state", context ->
{
context.contentType("application/json").result(new JSONObject(EsbFixtures.subscriptionState(instance)).toString());
});
config.routes.post("/acceptance/quick-view", context ->
{
seedQuickView();
@@ -190,8 +206,18 @@ public class AcceptanceSampleServer
//////////////////////////////////////////////////////////////////////////////////
server.withNextDashboardSecurityHeadersCustomizer(WidgetsFixtures::allowFakeService);
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
primeFixtures();
try
{
server.start();
EsbFixtures.awaitReady();
reset();
ready = true;
}
catch(Exception e)
{
server.stop();
throw e;
}
}
@@ -220,6 +246,7 @@ public class AcceptanceSampleServer
*******************************************************************************/
private static synchronized void reset() throws Exception
{
EsbFixtures.stopAndDrain(instance);
instance.getEnvironmentValues().remove("GOOGLE_APP_CLIENT_ID");
instance.getEnvironmentValues().remove("GOOGLE_APP_API_KEY");
if(instance.getSupplementalMetaData() != null)
@@ -229,6 +256,7 @@ public class AcceptanceSampleServer
SampleMetaDataProvider.primeTestDatabase("prime-test-database.sql");
SampleMetaDataProvider.primeTestDatabase("prime-sharing-database.sql");
primeFixtures();
EsbFixtures.start(instance);
}
@@ -244,6 +272,7 @@ public class AcceptanceSampleServer
{
throw new IllegalStateException("Acceptance fixtures require the sample in-memory H2 database.");
}
EsbFixtures.prime(connection);
NavigationFixtures.prime(connection);
RecordsFixtures.prime(connection);
FormAdjusterFixtures.prime(connection);
@@ -386,13 +415,22 @@ public class AcceptanceSampleServer
{
all.add(new AvailablePermission().withName(process + ".hasAccess").withObjectName(process).withObjectType("Process").withPermissionType("hasAccess"));
}
all.add(new AvailablePermission().withName("syncPerson.hasAccess").withObjectName("syncPerson").withObjectType("Process").withPermissionType("hasAccess"));
// Remove permission names, including grants shared by an app and a widget.
Set<String> appPermissions = all.stream().filter(permission -> "App".equals(permission.getObjectType()))
.map(AvailablePermission::getName).collect(Collectors.toSet());
Predicate<AvailablePermission> keep = switch(persona)
{
case "viewer" -> permission -> !"Process".equals(permission.getObjectType())
&& !permission.getName().matches(".*\\.(insert|edit|delete)$");
case "noPersonRead" -> permission -> !"person.read".equals(permission.getName());
case "noEsbView" -> permission -> !"esbView.hasAccess".equals(permission.getName());
case "noEsbOperate" -> permission -> !"esbOperate.hasAccess".equals(permission.getName());
case "noEsbDelete" -> permission -> !"esbDelete.hasAccess".equals(permission.getName());
case "noSyncPerson" -> permission -> !"syncPerson.hasAccess".equals(permission.getName());
case "noPets" -> permission -> !permission.getName().startsWith("pet.") && !permission.getName().startsWith("petNote.");
case "noProcesses" -> permission -> !"Process".equals(permission.getObjectType());
case "noApps" -> permission -> !"App".equals(permission.getObjectType());
case "noApps" -> permission -> !appPermissions.contains(permission.getName());
default -> permission -> true;
};
return all.stream().filter(keep).map(AvailablePermission::getName).collect(Collectors.toSet());
+265
View File
@@ -0,0 +1,265 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.esb.api.EsbRouteProvider;
import com.kingsrook.qqq.esb.metadata.EsbOverviewWidgetMetaDataProducer;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.esb.connection.EsbConnectionManager;
import com.kingsrook.qqq.esb.management.EsbBrokerNames;
import com.kingsrook.qqq.esb.model.EsbProcessMetaData;
import com.kingsrook.qqq.esb.model.EsbProviderType;
import com.kingsrook.qqq.esb.runtime.QEsbRuntime;
import com.kingsrook.qqq.esb.runtime.EsbTriggerState;
import com.kingsrook.qqq.esb.stats.EsbStats;
import com.kingsrook.qqq.esb.envelope.EsbEvent;
import com.kingsrook.sampleapp.metadata.SampleMetaDataProvider;
import com.kingsrook.sampleapp.processes.syncperson.SyncPersonStep;
import jakarta.jms.Message;
import jakarta.jms.MessageConsumer;
import jakarta.jms.MessageProducer;
import jakarta.jms.TemporaryQueue;
import org.apache.activemq.artemis.api.jms.management.JMSManagementHelper;
import org.apache.activemq.artemis.api.core.management.ResourceNames;
import jakarta.jms.Session;
/*******************************************************************************
** Uses the sample's real broker, publication, runtime and subscriber. Only the
** application step is augmented: record its effect in owned H2 and allow a
** controlled application failure to exercise real dead-lettering and replay.
*******************************************************************************/
public class EsbFixtures
{
private static final String PROVIDER = "sampleArtemis";
private static final String TRIGGER = "syncPerson.personEvents";
private static volatile boolean failSync;
public static void define(QInstance instance) throws QException
{
// Require production sample wiring; registering it here would conceal a
// stock sample integration defect and invalidate acceptance evidence.
if(instance.getApp("esb") == null || instance.getWidget(EsbOverviewWidgetMetaDataProducer.NAME) == null)
{
throw new QException("The sample JAR must register its ESB app and overview widget");
}
for(String name : List.of("esbPauseTrigger", "esbResumeTrigger", "esbRestartTrigger", "esbReplayDeadLetters",
"esbPauseQueue", "esbResumeQueue", "esbMoveMessages", "esbPurgeQueue", "esbDeleteMessages"))
{
if(instance.getProcess(name) == null)
{
throw new QException("The sample JAR must register its ESB process " + name);
}
}
List<QCodeReference> routes = QJavalinMetaData.ofOrWithNew(instance).getAdditionalRouteProviderReferences();
if(routes == null || routes.stream().noneMatch(route -> EsbRouteProvider.class.getName().equals(route.getName())))
{
throw new QException("The sample JAR must register EsbRouteProvider");
}
QBackendStepMetaData step = (QBackendStepMetaData) instance.getProcess("syncPerson").getStep("sync");
step.setCode(new QCodeReference(ReceiptStep.class));
EsbProcessMetaData.of(instance.getProcess("syncPerson")).getTriggers().get(0).setMaxAttempts(1);
}
public static void prime(Connection connection) throws Exception
{
failSync = false;
try(Statement statement = connection.createStatement())
{
statement.execute("DROP TABLE IF EXISTS acceptance_esb_receipt");
statement.execute("CREATE TABLE acceptance_esb_receipt (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, subject VARCHAR(100), event_type VARCHAR(200), first_name VARCHAR(200))");
}
}
public static void setFailure(boolean fail)
{
failSync = fail;
}
/*******************************************************************************
** Read the actual owned subscription's broker state. A PAUSED runner can
** still be finishing its outstanding receive; tests wait for zero consumers
** before publishing the message they require to remain on the broker.
*******************************************************************************/
public static Map<String, Long> subscriptionState(QInstance instance) throws Exception
{
QContext.init(instance, new QSession());
String subscription = EsbBrokerNames.subscriptionQueue(EsbProviderType.ACTIVEMQ_ARTEMIS, "personEvents", TRIGGER);
String queueName = subscription.substring(subscription.indexOf("::") + 2);
Map<String, Long> state = new java.util.LinkedHashMap<>();
try(Session session = EsbConnectionManager.getInstance().openSession(PROVIDER, false))
{
TemporaryQueue replies = session.createTemporaryQueue();
try(MessageConsumer consumer = session.createConsumer(replies);
MessageProducer producer = session.createProducer(session.createQueue("activemq.management")))
{
for(String attribute : List.of("consumerCount", "deliveringCount", "messageCount"))
{
Message request = session.createMessage();
JMSManagementHelper.putAttribute(request, ResourceNames.QUEUE + queueName, attribute);
request.setJMSReplyTo(replies);
producer.send(request);
Message reply = consumer.receive(5000);
if(reply == null || !JMSManagementHelper.hasOperationSucceeded(reply))
{
throw new IllegalStateException("Could not read owned subscription " + attribute);
}
state.put(attribute, ((Number) JMSManagementHelper.getResult(reply)).longValue());
}
}
finally
{
replies.delete();
}
}
return state;
}
/*******************************************************************************
** Stop first so no subscriber can race H2 reseeding or leave a delivery in
** flight. Drain only this fixture's queues; never touch unrelated brokers.
*******************************************************************************/
public static void stopAndDrain(QInstance instance) throws Exception
{
QEsbRuntime runtime = QEsbRuntime.getInstance();
runtime.stop();
if(runtime.getRunners().stream().anyMatch(runner -> runner.getState() != EsbTriggerState.STOPPED))
{
throw new IllegalStateException("ESB consumers did not stop before acceptance reset");
}
QContext.init(instance, new QSession());
try(Session session = EsbConnectionManager.getInstance().openSession(PROVIDER, true))
{
String subscription = EsbBrokerNames.subscriptionQueue(EsbProviderType.ACTIVEMQ_ARTEMIS, "personEvents", TRIGGER);
for(String name : List.of(subscription, "personEvents." + TRIGGER + ".dlq"))
{
try(MessageConsumer consumer = session.createConsumer(EsbConnectionManager.getInstance().resolveQueue(session, PROVIDER, name)))
{
resumeQueue(name);
int count = 0;
while(consumer.receive(100) != null)
{
session.commit();
if(++count > 10000)
{
throw new IllegalStateException("Acceptance queue drain exceeded its bound: " + name);
}
}
session.commit();
}
}
}
EsbConnectionManager.getInstance().closeAll();
EsbStats.getInstance().reset();
failSync = false;
}
/*******************************************************************************
** Reset uses Artemis's real management-message API (no HTTP adapter or mock).
** Queue consumers are created before this call so an empty DLQ exists too.
*******************************************************************************/
private static void resumeQueue(String brokerName) throws Exception
{
String queueName = brokerName.contains("::") ? brokerName.substring(brokerName.indexOf("::") + 2) : brokerName;
try(Session session = EsbConnectionManager.getInstance().openSession(PROVIDER, false))
{
TemporaryQueue replies = session.createTemporaryQueue();
try(MessageConsumer consumer = session.createConsumer(replies);
MessageProducer producer = session.createProducer(session.createQueue("activemq.management")))
{
Message request = session.createMessage();
JMSManagementHelper.putOperationInvocation(request, ResourceNames.QUEUE + queueName, "resume");
request.setJMSReplyTo(replies);
producer.send(request);
Message reply = consumer.receive(5000);
if(reply == null || !JMSManagementHelper.hasOperationSucceeded(reply))
{
throw new IllegalStateException("Could not resume owned acceptance queue " + queueName + ": " +
(reply == null ? "management response timed out" : JMSManagementHelper.getResult(reply)));
}
}
finally
{
replies.delete();
}
}
}
public static void start(QInstance instance) throws Exception
{
QEsbRuntime runtime = QEsbRuntime.getInstance();
runtime.start(instance);
awaitReady();
}
public static void awaitReady() throws Exception
{
QEsbRuntime runtime = QEsbRuntime.getInstance();
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
while(!runtime.isRunning() || runtime.getRunner(TRIGGER).getState() != EsbTriggerState.RUNNING)
{
if(System.nanoTime() >= deadline)
{
throw new IllegalStateException("Sample ESB trigger/control subscription was not ready within 15 seconds");
}
Thread.sleep(25);
}
}
public static class ReceiptStep extends SyncPersonStep
{
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(failSync)
{
throw new QException("Owned acceptance sync failure");
}
super.run(input, output);
@SuppressWarnings("unchecked")
List<EsbEvent> events = (List<EsbEvent>) input.getValue("esbMessages");
try(Connection connection = ConnectionManager.getConnection(SampleMetaDataProvider.defineRdbmsBackend());
PreparedStatement statement = connection.prepareStatement("INSERT INTO acceptance_esb_receipt (subject, event_type, first_name) VALUES (?, ?, ?)"))
{
for(EsbEvent event : events)
{
Map<?, ?> values = (Map<?, ?>) event.getData().get("record");
statement.setString(1, event.getSubject());
statement.setString(2, event.getType());
statement.setString(3, values == null ? null : String.valueOf(values.get("firstName")));
statement.executeUpdate();
}
}
catch(Exception e)
{
throw new QException("Failed to record the owned subscriber effect", e);
}
}
}
}
@@ -311,13 +311,16 @@ public class SecurityAcceptanceServer
static Set<String> permissionsFor(String persona)
{
Collection<AvailablePermission> all = PermissionsHelper.getAllAvailablePermissions(instance);
// Remove permission names, including grants shared by an app and a widget.
Set<String> appPermissions = all.stream().filter(permission -> "App".equals(permission.getObjectType()))
.map(AvailablePermission::getName).collect(Collectors.toSet());
Predicate<AvailablePermission> keep = switch(persona)
{
case "viewer" -> permission -> !"Process".equals(permission.getObjectType())
&& !permission.getName().matches(".*\\.(insert|edit|delete)$");
case "noPets" -> permission -> !permission.getName().startsWith("pet.") && !permission.getName().startsWith("petNote.");
case "noProcesses" -> permission -> !"Process".equals(permission.getObjectType());
case "noApps" -> permission -> !"App".equals(permission.getObjectType());
case "noApps" -> permission -> !appPermissions.contains(permission.getName());
default -> permission -> true;
};
return all.stream().filter(keep).map(AvailablePermission::getName).collect(Collectors.toSet());
+204
View File
@@ -0,0 +1,204 @@
{
"area": "esb",
"prefixes": [
"ESB"
],
"description": "Real embedded Artemis ESB workflows, permissions and supported management actions",
"rows": [
{
"id": "ESB-001",
"feature": "Admin overview and real table-to-process delivery",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Admin sees personEvents topic and syncPerson trigger",
"Create Person in the UI uses the real v1 API and increments consumed and succeeded; H2 receipt contains the inserted person"
],
"negative": [
"Fresh test starts with zero consumed and no receipts"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-002",
"feature": "Developer publications, subscribers and safe process links",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Person Developer view shows INSERT UPDATE DELETE and Sync Person subscriber",
"Following the process link opens its Developer view and exposes trigger metadata"
],
"negative": [
"No process init request or receipt from navigating metadata"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-003",
"feature": "Person READ permission",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"All-except-person-READ persona retains ESB app and process access"
],
"negative": [
"Table section is absent; its browser request and direct backend request return 403"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-004",
"feature": "ESB app permission",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Table READ independently allows the table section"
],
"negative": [
"No ESB navigation link and direct overview is 403 without esbView"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-005",
"feature": "Subscriber process permission",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Person publication remains visible without syncPerson access"
],
"negative": [
"Subscriber is omitted; process and dead-letter endpoints return 403"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-006",
"feature": "Trigger lifecycle and topic subscription",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Pause queues a real event; subscription browsing shows its body without consumption",
"Resume delivers and writes a receipt; restart completes and delivers the next event"
],
"negative": [
"No receipt while paused; each action reports its final completion"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-007",
"feature": "Operate permission enforcement",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Delete permission remains independent when operate is denied"
],
"negative": [
"Pause resume restart replay queue pause queue resume and move all return 403; controls absent"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-008",
"feature": "Delete permission enforcement",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Operate remains available without delete"
],
"negative": [
"Purge and delete requests return 403; destructive controls absent"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-009",
"feature": "Broker capability gating",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Real JMS subscription browsing works without managementUrl"
],
"negative": [
"Queue pause resume purge delete and move controls are absent when capabilities are false"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
},
{
"id": "ESB-010",
"feature": "Dead-letter replay confirmation and delivery",
"source": [
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
],
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
"scenarios": [
"Intentional application failure produces a real dead letter",
"Confirmed replay removes the dead letter and records successful subscriber effect"
],
"negative": [
"Unknown broker count is disclosed in confirmation",
"Opening and cancelling confirmation issues no replay and preserves the message"
],
"issues": [
"QRun-IO/qqq-frontend-next#10"
],
"required": true
}
]
}
+243
View File
@@ -0,0 +1,243 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { test, expect, open, type Backend } from '../../support/fixtures'
import type { EsbOverviewResponse, EsbTableResponse, EsbProcessResponse } from '../../../../src/types/esb'
const TRIGGER = 'syncPerson.personEvents'
const triggerRow = `[data-qqq-id="esb-trigger-${TRIGGER}"]`
async function overview(backend: Backend): Promise<EsbOverviewResponse> {
const response = await backend.api.get('/qqq/v1/esb/overview')
expect(response.status()).toBe(200)
expect(response.headers()['content-type'], 'ESB must return JSON, not an API docs or SPA fallback').toContain('application/json')
return response.json()
}
async function personStatus(backend: Backend): Promise<EsbTableResponse> {
const response = await backend.api.get('/qqq/v1/esb/table/person')
expect(response.status()).toBe(200)
expect(response.headers()['content-type'], 'ESB must return JSON, not an API docs or SPA fallback').toContain('application/json')
return response.json()
}
async function insertPerson(backend: Backend, firstName: string) {
const response = await backend.api.post('/qqq/v1/table/person', {
multipart: { firstName, lastName: 'ESB Acceptance', email: `${firstName.toLowerCase()}@example.invalid` },
})
expect(response.status()).toBe(200)
return response.json()
}
async function receipts(backend: Backend) {
return backend.sql('SELECT subject, event_type, first_name FROM acceptance_esb_receipt ORDER BY id')
}
test('[ESB-001] admin sees the real topic and trigger; inserting a person runs its subscriber', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/esb')
await expect(page.locator('[data-qqq-id="esb-destination-personEvents"]')).toContainText('Topic')
const row = page.locator(triggerRow)
await expect(row).toContainText('Sync Person')
await expect(row).toContainText('Running')
const before = (await overview(backend)).destinations.find((destination) => destination.name === 'personEvents')!
expect(before.triggers[0].counters.consumed).toBe(0)
expect(await receipts(backend)).toEqual([])
await open(page, '/app/person/create')
await page.locator('#field-firstName').fill('River')
await page.locator('#field-lastName').fill('ESB Acceptance')
await page.locator('#field-email').fill('river@example.invalid')
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.getByRole('heading', { level: 1, name: 'River ESB Acceptance' })).toBeVisible()
await expect.poll(async () => (await personStatus(backend)).subscribers[0].counters.succeeded).toBe(1)
const status = await personStatus(backend)
expect(status.subscribers[0].counters).toMatchObject({ consumed: 1, succeeded: 1, failed: 0, inFlight: 0 })
const people = await backend.sql("SELECT id FROM person WHERE first_name = 'River'")
expect(await receipts(backend)).toEqual([{ subject: people[0].id, event_type: 'qqq.table.person.inserted', first_name: 'River' }])
await open(page, '/app/esb')
await expect(row.locator('div').filter({ has: page.locator('dt').filter({ hasText: /^Consumed$/ }) }).locator('dd')).toHaveText('1')
})
test('[ESB-002] table and process Developer views expose publications and subscribers without running a process', async ({ page, backend, diagnostics }) => {
void diagnostics
const processInits: string[] = []
page.on('request', (request) => {
if (request.method() === 'POST' && /\/qqq\/v1\/processes\/.*\/init$/.test(new URL(request.url()).pathname)) processInits.push(request.url())
})
await open(page, '/app/person/dev')
const section = page.locator('[data-qqq-id="esb-section-person"]')
await expect(section.getByRole('table', { name: 'Publications', exact: true })).toContainText('INSERT, UPDATE, DELETE')
await expect(section.getByRole('table', { name: 'Subscribers', exact: true })).toContainText('Sync Person')
const link = section.getByRole('link', { name: 'Sync Person', exact: true })
await expect(link).toHaveAttribute('href', /^\/app\/syncPerson\/dev\/?$/)
await link.click()
await expect(page).toHaveURL(/\/app\/syncPerson\/dev\/?$/)
await expect(page.locator('[data-qqq-id="esb-section-syncPerson"]')).toContainText('personEvents')
const response = await backend.api.get('/qqq/v1/esb/process/syncPerson')
expect(response.status()).toBe(200)
const process: EsbProcessResponse = await response.json()
expect(process.triggers.map((trigger) => trigger.name)).toEqual([TRIGGER])
expect(processInits).toEqual([])
expect(await receipts(backend)).toEqual([])
})
test.describe('table READ enforcement', () => {
test.use({ persona: 'noPersonRead' })
test('[ESB-003] no person READ hides the section and the server refuses its data', async ({ page, backend, diagnostics }) => {
diagnostics.allow(/^request: GET \/qqq\/v1\/esb\/table\/person 403$/)
diagnostics.allow(/^console: Failed to load resource: the server responded with a status of 403 \(Forbidden\)$/)
const denied = page.waitForResponse((response) => new URL(response.url()).pathname === '/qqq/v1/esb/table/person')
await open(page, '/app/person/dev')
expect((await denied).status()).toBe(403)
await expect(page.locator('[data-qqq-id="esb-section-person"]')).toHaveCount(0)
expect((await backend.api.get('/qqq/v1/esb/table/person')).status()).toBe(403)
// This persona still has ESB app and process access; only person.read is removed.
expect((await overview(backend)).destinations.map((destination) => destination.name)).toContain('personEvents')
expect((await backend.api.get('/qqq/v1/esb/process/syncPerson')).status()).toBe(200)
})
})
test.describe('app enforcement', () => {
test.use({ persona: 'noEsbView' })
test('[ESB-004] no ESB app access hides navigation and denies overview while table READ still works', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/person/dev')
await expect(page.locator('[data-qqq-id="esb-section-person"]')).toBeVisible()
await expect(page.getByRole('link', { name: 'ESB', exact: true })).toHaveCount(0)
expect((await backend.api.get('/qqq/v1/esb/overview')).status()).toBe(403)
expect((await personStatus(backend)).subscribers[0].name).toBe(TRIGGER)
})
})
test.describe('subscriber enforcement', () => {
test.use({ persona: 'noSyncPerson' })
test('[ESB-005] a denied process is omitted from subscribers and its endpoints refuse access', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/person/dev')
const section = page.locator('[data-qqq-id="esb-section-person"]')
await expect(section).toContainText('personEvents')
await expect(section).toContainText('No processes you can access are triggered by these destinations.')
await expect(section.getByRole('link', { name: 'Sync Person', exact: true })).toHaveCount(0)
expect((await personStatus(backend)).subscribers).toEqual([])
expect((await backend.api.get('/qqq/v1/esb/process/syncPerson')).status()).toBe(403)
expect((await backend.api.get(`/qqq/v1/esb/deadLetters/${TRIGGER}`)).status()).toBe(403)
})
})
test('[ESB-006] pause holds a real message in the topic subscription; resume and restart complete', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/esb')
const row = page.locator(triggerRow)
await row.getByRole('button', { name: 'Pause Sync Person', exact: true }).click()
await expect.poll(async () => (await personStatus(backend)).subscribers[0].state).toBe('PAUSED')
await expect(page.getByText('esbPauseTrigger completed.', { exact: true })).toBeVisible()
// PAUSED is a requested state: the runner closes its consumer after the
// outstanding receive finishes. Observe the real broker boundary, not time.
await expect.poll(() => backend.esbSubscriptionState()).toEqual({ consumerCount: 0, deliveringCount: 0, messageCount: 0 })
await insertPerson(backend, 'Paused')
await expect.poll(() => backend.esbSubscriptionState()).toEqual({ consumerCount: 0, deliveringCount: 0, messageCount: 1 })
await row.getByRole('button', { name: /Browse subscription/ }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByRole('table', { name: 'Messages', exact: true })).toContainText('qqq.table.person.inserted')
await dialog.getByText('Body', { exact: true }).click()
await expect(dialog.locator('pre')).toContainText('Paused')
expect((await personStatus(backend)).subscribers[0].counters.consumed).toBe(0)
expect(await receipts(backend)).toEqual([])
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
await row.getByRole('button', { name: 'Resume Sync Person', exact: true }).click()
await expect(page.getByText('esbResumeTrigger completed.', { exact: true })).toBeVisible()
await expect.poll(async () => (await personStatus(backend)).subscribers[0].counters.succeeded).toBe(1)
expect((await receipts(backend)).map((receipt) => receipt.first_name)).toEqual(['Paused'])
await row.getByRole('button', { name: 'Restart Sync Person', exact: true }).click()
await expect(page.getByText('esbRestartTrigger completed.', { exact: true })).toBeVisible()
await expect.poll(async () => (await personStatus(backend)).subscribers[0].state).toBe('RUNNING')
await insertPerson(backend, 'Restarted')
await expect.poll(async () => (await receipts(backend)).map((receipt) => receipt.first_name)).toEqual(['Paused', 'Restarted'])
})
test.describe('operate permission', () => {
test.use({ persona: 'noEsbOperate' })
test('[ESB-007] operate controls are absent and every operate process denies direct invocation', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/esb')
const row = page.locator(triggerRow)
await expect(row).toContainText('Sync Person')
await expect(row.getByRole('button', { name: /^(Pause|Resume|Restart|Replay)/ })).toHaveCount(0)
expect((await overview(backend)).permissions).toEqual({ canOperate: false, canDelete: true })
for (const process of ['esbPauseTrigger', 'esbResumeTrigger', 'esbRestartTrigger', 'esbReplayDeadLetters', 'esbPauseQueue', 'esbResumeQueue', 'esbMoveMessages']) {
const response = await backend.api.post(`/qqq/v1/processes/${process}/init`, { multipart: { values: JSON.stringify({ triggerName: TRIGGER, providerName: 'sampleArtemis', brokerQueueName: 'personEvents', all: true }) } })
expect(response.status(), process).toBe(403)
}
expect((await personStatus(backend)).subscribers[0].state).toBe('RUNNING')
expect(await receipts(backend)).toEqual([])
})
})
test.describe('delete permission', () => {
test.use({ persona: 'noEsbDelete' })
test('[ESB-008] delete permission is independent of operate and enforced by both destructive processes', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/esb')
const row = page.locator(triggerRow)
await expect(row.getByRole('button', { name: 'Pause Sync Person', exact: true })).toBeVisible()
expect((await overview(backend)).permissions).toEqual({ canOperate: true, canDelete: false })
await row.getByRole('button', { name: 'Browse dead letters for Sync Person', exact: true }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('No messages.', { exact: true })).toBeVisible()
await expect(dialog.getByRole('button', { name: /^(Purge|Delete)/ })).toHaveCount(0)
for (const process of ['esbPurgeQueue', 'esbDeleteMessages']) {
const response = await backend.api.post(`/qqq/v1/processes/${process}/init`, { multipart: { values: JSON.stringify({ providerName: 'sampleArtemis', brokerQueueName: `personEvents.${TRIGGER}.dlq`, olderThan: '2099-01-01T00:00:00Z' }) } })
expect(response.status(), process).toBe(403)
}
})
})
test('[ESB-009] unavailable broker capabilities hide queue mutations, while JMS browsing remains usable', async ({ page, backend, diagnostics }) => {
void diagnostics
await open(page, '/app/esb')
const status = await overview(backend)
expect(status.providers.find((provider) => provider.name === 'sampleArtemis')).toMatchObject({ connected: true, managementEnabled: false })
expect(status.destinations.find((destination) => destination.name === 'personEvents')?.capabilities).toEqual({ browse: true, pauseQueue: false, purge: false, deleteSelected: false, deleteOlderThan: false, move: false })
await page.locator(triggerRow).getByRole('button', { name: /Browse subscription/ }).click()
await expect(page.getByRole('dialog').getByText('No messages.', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /^(Pause queue|Resume queue|Purge|Delete older|Delete selected|Move selected)/ })).toHaveCount(0)
})
test('[ESB-010] real dead letters require replay-all confirmation; cancel preserves them and confirm runs the subscriber', async ({ page, backend, diagnostics }) => {
void diagnostics
await backend.failEsbSync(true)
await insertPerson(backend, 'Replay')
await expect.poll(async () => (await personStatus(backend)).subscribers[0].counters.deadLettered).toBe(1)
const deadLetters = async () => {
const response = await backend.api.get(`/qqq/v1/esb/deadLetters/${TRIGGER}`)
expect(response.status()).toBe(200)
return response.json()
}
expect((await deadLetters()).messages).toHaveLength(1)
expect(await receipts(backend)).toEqual([])
await backend.failEsbSync(false)
await open(page, '/app/esb')
const row = page.locator(triggerRow)
const replays: string[] = []
page.on('request', (request) => { if (new URL(request.url()).pathname === '/qqq/v1/processes/esbReplayDeadLetters/init') replays.push(request.postData() ?? '') })
await row.getByRole('button', { name: 'Replay dead letters for Sync Person', exact: true }).click()
const confirmation = page.getByRole('alertdialog')
await expect(confirmation).toContainText(/dead letters/i)
// No management API: the dialog must acknowledge an unknown count, never invent zero.
await expect(confirmation).toContainText(/unknown|unavailable|cannot.*count/i)
expect(replays).toEqual([])
await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
expect((await deadLetters()).messages).toHaveLength(1)
expect(await receipts(backend)).toEqual([])
await row.getByRole('button', { name: 'Replay dead letters for Sync Person', exact: true }).click()
await confirmation.locator('[data-qqq-id="button-esb-confirm"]').click()
await expect(page.getByText('esbReplayDeadLetters completed.', { exact: true })).toBeVisible()
expect(replays).toHaveLength(1)
await expect.poll(async () => (await deadLetters()).messages.length).toBe(0)
expect((await receipts(backend)).map((receipt) => receipt.first_name)).toEqual(['Replay'])
})
@@ -55,6 +55,8 @@ test.describe('routing', () => {
await page.getByRole('navigation', { name: 'Breadcrumb' }).getByRole('link', { name: 'Greetings App' }).click()
await expect(page).toHaveURL(/\/app\/greetingsApp\/?$/)
await expect(page.getByRole('heading', { level: 1, name: 'Greetings App' })).toBeVisible()
// Complete this destination before the next hard navigation tears down its owned iframe (#972).
await expect(page.frameLocator('[data-qqq-id="quicksight-QuickSightChartRenderer"]').getByRole('heading', { name: 'Owned embedded chart' })).toBeVisible()
// The dashboard root has no breadcrumbs
await open(page, '/app')
@@ -91,6 +93,8 @@ test.describe('routing', () => {
test('[NAV-016] the site root replaces itself with the dashboard @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/person')
await waitForShell(page)
// Let the initial document finish rendering its fonts before navigation tears it down (#951).
await page.evaluate(() => document.fonts.ready.then(() => undefined))
await open(page, '/')
await expect(page).toHaveURL(/\/app\/?$/)
await expect(page.getByText(/Here's an overview of your QQQ Sample system\./)).toBeVisible()
@@ -24,6 +24,7 @@ async function fillMixed(page: Page, name: string, count: string) {
await mixed.getByLabel('Lab Name').fill(name)
await mixed.getByLabel('Sample Count').fill(count)
await choosePossibleValue(page, 'Lab Color', 'Green')
await expect(page.getByRole('combobox', { name: 'Lab Color' })).toContainText('Green')
await advance(page, 'Next')
}
+4 -2
View File
@@ -7,6 +7,7 @@
import { readFileSync } from 'node:fs'
import type { APIRequestContext, Page } from '@playwright/test'
import { expectedDownloadFilename } from '../../support/downloads'
import { expect, test } from '../../support/fixtures'
import { advance, expectRunTouchReady, expectScreen, openProcess } from './process-helpers'
@@ -365,16 +366,17 @@ test.describe('Bulk load', () => {
expect(await backend.sql('select count(*) as n from saved_bulk_load_profile')).toEqual([{ n: '0' }])
})
test('[PRC-034] upload instructions offer a downloadable template file @mobile', async ({ page, diagnostics }) => {
test('[PRC-034] upload instructions offer a downloadable template file @mobile', async ({ page, browserName, diagnostics }) => {
void diagnostics
await openProcess(page, 'person.bulkInsert')
const upload = await expectScreen(page, 'upload', 'Upload File')
await upload.getByText('File Upload Instructions').click()
const template = upload.getByRole('link', { name: 'Person - Flat.csv' })
await expect(template).toBeVisible()
await expect(template).toHaveAttribute('download', 'Person - Flat.csv')
await expectRunTouchReady(page, 'person.bulkInsert')
const [download] = await Promise.all([page.waitForEvent('download'), template.click()])
expect(download.suggestedFilename()).toBe('Person - Flat.csv')
expect(download.suggestedFilename()).toBe(expectedDownloadFilename('Person - Flat.csv', browserName))
expect(readFileSync(await download.path(), 'utf8').split('\n')[0]).toBe('Email,First Name,Last Name,Annual Salary,Birth Date,Days Worked,Is Employed')
})
})
@@ -8,6 +8,7 @@
// Backend exports (CSV, XLSX, JSON) of the query's filter, sort and visible columns.
import { readFileSync } from 'node:fs'
import type { Page } from '@playwright/test'
import { expectedDownloadFilename } from '../../support/downloads'
import { expect, open, test } from '../../support/fixtures'
import { expectWithinViewport } from '../../support/touch'
import { addCondition, closeFilterSheet, expectColumn, grid, openFilter, showTable, sqlColumn } from './query-helpers'
@@ -34,11 +35,12 @@ function parseCsv(text: string): string[][] {
async function download(page: Page, format: 'CSV' | 'XLSX' | 'JSON') {
await page.getByRole('button', { name: 'Export records' }).click()
await expectWithinViewport(page.getByRole('menu', { name: 'Export options' }))
const [file] = await Promise.all([
const [file, request] = await Promise.all([
page.waitForEvent('download'),
page.waitForRequest((request) => request.method() === 'POST' && /\/table\/[^/]+\/export$/.test(new URL(request.url()).pathname)),
page.getByRole('menuitem', { name: new RegExp(`^Export ${format}`) }).click(),
])
return { name: file.suggestedFilename(), body: readFileSync((await file.path())!) }
return { name: file.suggestedFilename(), requestedName: request.postDataJSON().filename as string, body: readFileSync((await file.path())!) }
}
/** Grid header labels in display order. */
@@ -46,7 +48,7 @@ async function headers(page: Page, tableLabel: string) {
return grid(page, tableLabel).locator('thead th button[data-qqq-id^="grid-header-"]').allTextContents()
}
test('[QRY-040] CSV export has the visible columns in order and exactly the filtered, sorted rows @mobile', async ({ page, backend, diagnostics }) => {
test('[QRY-040] CSV export has the visible columns in order and exactly the filtered, sorted rows @mobile', async ({ page, backend, browserName, diagnostics }) => {
void diagnostics
await open(page, '/app/qryItem')
await showTable(page)
@@ -67,7 +69,8 @@ test('[QRY-040] CSV export has the visible columns in order and exactly the filt
await expectColumn(page, 'id', ids)
const file = await download(page, 'CSV')
expect(file.name).toMatch(/^Query Item Export \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(file.requestedName).toMatch(/^Query Item Export \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(file.name).toBe(expectedDownloadFilename(file.requestedName, browserName))
const [header, ...rows] = parseCsv(file.body.toString('utf8'))
// The backend adds a "<label> Name" column after each possible-value field
const expectedHeader = (await headers(page, 'Query Item')).flatMap((label) => ['Owner', 'Species'].includes(label) ? [label, `${label} Name`] : [label])
@@ -8,6 +8,7 @@
// Phone and tablet query behavior (QRun-IO/qqq#708): the card list, the filter sheet, column
// configuration by touch, bulk actions from a phone selection, and the toolbar menus.
import type { Locator, Page } from '@playwright/test'
import { expectedDownloadFilename } from '../../support/downloads'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets, expectWithinViewport } from '../../support/touch'
import { addCondition, columnCells, expectColumn, isPhone, sqlColumn } from './query-helpers'
@@ -183,7 +184,7 @@ test.describe('on a phone', () => {
expect(sent.values.map(String).sort()).toEqual([ids[0], ids[2]].sort())
})
test('[QRY-055] saved views and export menus fit the phone and are touch-sized @mobile', async ({ page, backend, diagnostics }) => {
test('[QRY-055] saved views and export menus fit the phone and are touch-sized @mobile', async ({ page, backend, browserName, diagnostics }) => {
void diagnostics
await open(page, '/app/person')
await expectColumn(page, 'firstName', await sqlColumn(backend, 'select first_name from person order by id desc'))
@@ -202,8 +203,14 @@ test.describe('on a phone', () => {
await expectOnScreen(page, exportMenu)
await expectTouchTargets(exportMenu)
await expectNoHorizontalScroll(page)
const [file] = await Promise.all([page.waitForEvent('download'), page.getByRole('menuitem', { name: /^Export CSV/ }).tap()])
expect(file.suggestedFilename()).toMatch(/^Person Export .*\.csv$/)
const [file, request] = await Promise.all([
page.waitForEvent('download'),
page.waitForRequest((request) => request.method() === 'POST' && new URL(request.url()).pathname === '/qqq/v1/table/person/export'),
page.getByRole('menuitem', { name: /^Export CSV/ }).tap(),
])
const requestedName = request.postDataJSON().filename as string
expect(requestedName).toMatch(/^Person Export \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(file.suggestedFilename()).toBe(expectedDownloadFilename(requestedName, browserName))
})
})
+4 -2
View File
@@ -7,10 +7,11 @@
import { readFileSync } from 'node:fs'
import type { Request } from '@playwright/test'
import { expectedDownloadFilename } from '../../support/downloads'
import { expect, open, test } from '../../support/fixtures'
import { expectColumn, sqlColumn } from './query-helpers'
test('[QRY-065] export streams from the v1 export route and the route enforces the export capability @mobile', async ({ page, backend, diagnostics }) => {
test('[QRY-065] export streams from the v1 export route and the route enforces the export capability @mobile', async ({ page, backend, browserName, diagnostics }) => {
void diagnostics
const exports: Request[] = []
page.on('request', (request) => { if (/\/export(\/|$)/.test(new URL(request.url()).pathname)) exports.push(request) })
@@ -27,7 +28,8 @@ test('[QRY-065] export streams from the v1 export route and the route enforces t
// The page used the v1 route with a JSON body naming the format, file and columns.
expect(exports.map((request) => `${request.method()} ${new URL(request.url()).pathname}`)).toEqual(['POST /qqq/v1/table/qryItem/export'])
const body = exports[0].postDataJSON()
expect(body).toMatchObject({ format: 'csv', filename: file.suggestedFilename() })
expect(body).toMatchObject({ format: 'csv', filename: expect.stringMatching(/^Query Item Export \d{4}-\d{2}-\d{2} \d{4}\.csv$/) })
expect(file.suggestedFilename()).toBe(expectedDownloadFilename(body.filename, browserName))
expect(body.fieldNames).toContain('id')
// The v1 route streams the same rows, and refuses a table whose export capability is off.
+3 -6
View File
@@ -8,7 +8,7 @@
import { expect, test } from '../../support/fixtures'
import { listCell } from '../security/support/ui'
import {
expectToastBelowHeader,
expectToastDuringAction,
VIEWER, control, expandOnPhone, fieldValue, multipartFields, openForm, openRecord, recordAction, recordIdFromUrl, recordRequests, sqlCount, sqlOne, toasts,
} from './helpers'
@@ -51,12 +51,10 @@ test('[REC-006] create persists the entered values and opens the new record @mob
await control(page, 'annualSalary').fill('64250.75')
await control(page, 'daysWorked').fill('321')
await page.getByRole('checkbox', { name: 'Is Employed' }).click()
await page.getByRole('button', { name: 'Save' }).click()
await expectToastDuringAction(page, 'Person created successfully.', () => page.getByRole('button', { name: 'Save' }).click(), true)
await expect(page.getByRole('heading', { level: 1, name: 'Quinn Acceptance' })).toBeVisible()
const id = recordIdFromUrl(page, 'person')
await expect(toasts(page).filter({ hasText: 'Person created successfully.' })).toBeVisible()
await expectToastBelowHeader(page, toasts(page).filter({ hasText: 'Person created successfully.' }))
await expandOnPhone(page, 'Employment Info')
const row = await sqlOne(backend, `select first_name, last_name, email, birth_date, annual_salary, days_worked, is_employed from person where id = ${id}`)
expect(row).toEqual({ first_name: 'Quinn', last_name: 'Acceptance', email: 'quinn@example.invalid', birth_date: '1988-07-04',
@@ -129,11 +127,10 @@ test('[REC-009] edit prefills stored values, saves only the change and shows the
await control(page, 'email').fill('avery.updated@example.invalid')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Save' }).click()
await expectToastDuringAction(page, 'Person saved successfully.', () => page.getByRole('button', { name: 'Save' }).click())
await expect(page.getByRole('heading', { level: 1, name: 'Avery Sample' })).toBeVisible()
await expect(fieldValue(page, 'email')).toHaveText('avery.updated@example.invalid')
await expect(toasts(page).filter({ hasText: 'Person saved successfully.' })).toBeVisible()
const put = writes.filter((request) => request.method() === 'PATCH')
expect(put).toHaveLength(1)
@@ -105,14 +105,17 @@ async function revisions(backend: Backend) {
test.describe('table developer view', () => {
test('[REC-053] keeps the metadata sections and shows API Docs & Playground with no application APIs @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
diagnostics.allow(/^request: GET \/qqq\/v1\/esb\/table\/recordLab 404$/)
diagnostics.allow(/^console: Failed to load resource: the server responded with a status of 404 \(Not Found\)$/)
const table = await (await backend.api.get('/qqq/v1/metaData/table/recordLab')).json()
const catalog = await backend.api.get('/apis.json?tableName=recordLab')
expect(catalog.status()).toBe(200)
expect(await catalog.json()).toEqual({ apis: [] })
const specRequests = requestsMatching(page, /(versions|openapi)\.json$/)
const esbResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/qqq/v1/esb/table/recordLab')
await open(page, '/app/recordLab/dev')
expect((await esbResponse).status()).toBe(404)
await expect(page.getByRole('heading', { level: 2, name: `Table Developer View: ${table.label}` })).toBeVisible()
await expectNoSidewaysScroll(page)
await expectTouchTargets(page.locator('#main-content'))
@@ -144,7 +147,7 @@ test.describe('table developer view', () => {
expect(json.label).toBe(table.label)
expect(Object.keys(json.fields).sort()).toEqual(Object.keys(table.fields).sort())
expect(diagnostics.failedRequests).toEqual([])
expect(diagnostics.failedRequests).toEqual(['GET /qqq/v1/esb/table/recordLab 404'])
expect(specRequests.map((request) => request.url())).toEqual([])
})
@@ -152,7 +155,8 @@ test.describe('table developer view', () => {
test.use({ persona: 'viewer' })
test('[REC-053] a viewer sees the same empty state and cannot fetch any table spec @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
diagnostics.allow(/^request: GET \/qqq\/v1\/esb\/table\/recordLab 404$/)
diagnostics.allow(/^console: Failed to load resource: the server responded with a status of 404 \(Not Found\)$/)
const table = await (await backend.api.get('/qqq/v1/metaData/table/recordLab')).json()
expect([table.insertPermission, table.editPermission, table.deletePermission]).toEqual([false, false, false])
// No API lists the table for this session either, so there is no spec URL to fetch.
@@ -161,7 +165,9 @@ test.describe('table developer view', () => {
expect(await catalog.json()).toEqual({ apis: [] })
const specRequests = requestsMatching(page, /(versions|openapi)\.json$/)
const esbResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/qqq/v1/esb/table/recordLab')
await open(page, '/app/recordLab/dev')
expect((await esbResponse).status()).toBe(404)
await expect(page.getByRole('heading', { level: 2, name: `Table Developer View: ${table.label}` })).toBeVisible()
await expect(page.locator('p', { hasText: /^Capabilities$/ }).locator('xpath=following-sibling::p')).toHaveText('Read-only')
const docs = byId(page, 'table-dev-api-docs')
@@ -172,7 +178,7 @@ test.describe('table developer view', () => {
await expect(byId(page, 'table-dev-api-reference')).toHaveCount(0)
await expect(page.locator('rapi-doc')).toHaveCount(0)
expect(diagnostics.failedRequests).toEqual([])
expect(diagnostics.failedRequests).toEqual(['GET /qqq/v1/esb/table/recordLab 404'])
expect(specRequests.map((request) => request.url())).toEqual([])
})
})
@@ -6,10 +6,11 @@
*/
import type { Page } from '@playwright/test'
import { expectedDownloadFilename } from '../../support/downloads'
import { expect, test, type Backend } from '../../support/fixtures'
import {
VIEWER, control, expandOnPhone, fieldValue, multipartFields, openForm, openRecord, recordIdFromUrl, recordRequests, showSection, shown, sqlCount,
sqlOne, toasts,
sqlOne, expectToastDuringAction,
} from './helpers'
test.use(VIEWER)
@@ -275,7 +276,7 @@ test('[REC-021] PASSWORD is masked on read and never overwritten by the mask @mo
expect((await sqlOne(backend, `select password_value from field_lab where id = ${id}`)).password_value).toBe('secret-two')
})
test('[REC-022] BLOB upload stores the bytes and the view downloads them @mobile', async ({ page, backend, diagnostics }) => {
test('[REC-022] BLOB upload stores the bytes and the view downloads them @mobile', async ({ page, backend, browserName, diagnostics }) => {
void diagnostics
await openForm(page, '/app/fieldLab/create', 'Create Field Lab')
await control(page, 'name').fill('Binary')
@@ -290,9 +291,11 @@ test('[REC-022] BLOB upload stores the bytes and the view downloads them @mobile
await expandOnPhone(page, 'Field Types')
const downloadPromise = page.waitForEvent('download')
await shown(page, '[data-qqq-id="field-value-blobValue-download"]').click()
const link = shown(page, '[data-qqq-id="field-value-blobValue-download"]')
await expect(link).toHaveAttribute('download', `Field Lab ${id} Blob Value`)
await link.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe(`Field Lab ${id} Blob Value`)
expect(download.suggestedFilename()).toBe(expectedDownloadFilename(`Field Lab ${id} Blob Value`, browserName))
const chunks: Buffer[] = []
for await (const chunk of await download.createReadStream()) chunks.push(chunk as Buffer)
expect(Buffer.concat(chunks).equals(Buffer.from(stored, 'base64'))).toBe(true)
@@ -404,13 +407,11 @@ test('[REC-027] unique keys are enforced with the backend message on create and
test('[REC-048] a server validation error is shown once with the backend message and keeps the form @mobile', async ({ page, backend, diagnostics }) => {
for (const pattern of FIELD_LAB_ERRORS) diagnostics.allow(pattern)
await createFieldLab(page, { name: 'Kept Values', decimalValue: '42.25', boundedValue: '101' })
await openForm(page, '/app/fieldLab/create', 'Create Field Lab')
for (const [name, value] of Object.entries({ name: 'Kept Values', decimalValue: '42.25', boundedValue: '101' })) await control(page, name).fill(value)
const message = 'Error inserting Field Lab: The value for Bounded Value is too large (maximum allowed value is 100)'
await expectToastDuringAction(page, `Failed to create Field Lab: ${message}`, () => page.getByRole('button', { name: 'Save' }).click())
await expect(page.getByRole('alert').filter({ hasText: message })).toBeVisible()
await expect(toasts(page).filter({ hasText: `Failed to create Field Lab: ${message}` })).toBeVisible()
await expect(toasts(page)).toHaveCount(1)
await expect(toasts(page).filter({ hasText: 'Something went wrong' })).toHaveCount(0)
await expect(toasts(page).filter({ hasText: 'Request failed with status code' })).toHaveCount(0)
await expect(control(page, 'name')).toHaveValue('Kept Values')
await expect(control(page, 'decimalValue')).toHaveValue('42.25')
await expect(page).toHaveURL(/\/app\/fieldLab\/create\/?$/)
+14
View File
@@ -7,6 +7,7 @@
import type { Locator, Page, Request } from '@playwright/test'
import { expect, type Backend } from '../../support/fixtures'
import { visibleToastObservation } from '../../support/toast-observation'
import { expectNoHorizontalScroll } from '../../support/touch'
import { isPhone } from '../query/query-helpers'
@@ -77,6 +78,19 @@ export async function expectToastBelowHeader(page: Page, toast: Locator) {
await expect.poll(async () => (await toast.boundingBox())?.y ?? -1).toBeGreaterThanOrEqual(header!.y + header!.height)
}
/**
* Observe transient feedback before triggering its action. Browser-side polling captures
* the visible message, count and geometry even when the driver returns after dismissal.
*/
export async function expectToastDuringAction(page: Page, message: string, action: () => Promise<unknown>, belowHeader = false) {
const [observation] = await Promise.all([
page.waitForFunction(visibleToastObservation, { message, belowHeader }, { timeout: 15_000 }),
action(),
])
expect(await observation.jsonValue(), 'one visible notification with the exact message').toEqual([message])
await observation.dispose()
}
/** Reads exactly one row with an independent SQL query. */
export async function sqlOne(backend: Backend, query: string): Promise<Record<string, string | null>> {
const rows = await backend.sql(query)
@@ -0,0 +1,25 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { expect, test } from '../../support/fixtures'
import { control, expectToastDuringAction, openForm, sqlCount, toasts } from './helpers'
test('[REC-006] success feedback is observed when action delivery returns after dismissal @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
await openForm(page, '/app/person/create', 'Create Person')
await control(page, 'firstName').fill('Feedback')
await control(page, 'lastName').fill('Observation')
await control(page, 'email').fill('feedback@example.invalid')
await expectToastDuringAction(page, 'Person created successfully.', async () => {
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('heading', { level: 1, name: 'Feedback Observation' })).toBeVisible()
// Model the hosted trace/driver delay without extending the product notification lifetime.
await expect(toasts(page)).toHaveCount(0)
}, true)
expect(await sqlCount(backend, "select count(*) as n from person where email = 'feedback@example.invalid'")).toBe(1)
})
@@ -95,12 +95,11 @@ test.describe('dashboard security headers', () => {
expect(framed.status()).toBe(200)
expect(parsePolicy(framed.headers()['content-security-policy'])['frame-ancestors']).toEqual(["'none'"])
expect(framed.headers()['x-frame-options']).toBe('DENY')
await page.waitForTimeout(1_000)
await expect(page.frameLocator('#victim').locator('[data-qqq-id="login-card"]')).toHaveCount(0)
await expect(page.frameLocator('#victim').getByText(/Signing in|Unable to sign in|You have signed out/)).toHaveCount(0)
for (const frame of page.frames().filter((candidate) => candidate !== page.mainFrame())) {
expect(await frame.locator('[data-qqq-id="login-card"]').count()).toBe(0)
}
// A denied Firefox frame may have no script context to query (#981). Require its actual
// refusal, in addition to the response headers, without entering that blocked document.
await expect.poll(() => diagnostics.consoleErrors.some((message) =>
/frame-ancestors|X-Frame-Options/i.test(message) && /blocked|refused|violates/i.test(message),
), { message: 'the browser must report refusing the dashboard frame' }).toBe(true)
} finally {
await otherSite.close()
}
@@ -185,6 +185,54 @@ tableTest('[SEC-048] [SEC-049] TABLE_BASED sign-in, reload resume, idle expiry a
expect(sessionRequests).toEqual([])
})
tableTest('[SEC-049] a pending logout finishes cookie expiry before another user can sign in @mobile', async ({ page, tableBased, diagnostics, context }) => {
void tableBased
void diagnostics
await open(page, '/app/person')
await submitCredentials(page, TESS)
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
let releaseLogout!: () => void
let reportLogoutStarted!: () => void
const logoutGate = new Promise<void>((resolve) => { releaseLogout = resolve })
const logoutStarted = new Promise<void>((resolve) => { reportLogoutStarted = resolve })
await page.route('**/qqq/v1/logout', async (route) => {
reportLogoutStarted()
await logoutGate
await route.continue()
})
const logoutResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/qqq/v1/logout')
try {
const menu = await openUserMenu(page)
await menu.getByRole('menuitem', { name: 'Log Out' }).click()
await logoutStarted
// Hold the real logout request; its eventual response must expire the old cookies first.
await expect(page.getByRole('status', { name: 'Loading', exact: true })).toBeVisible()
await expect(page.getByLabel('Username', { exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toHaveCount(0)
releaseLogout()
const response = await logoutResponse
expect(response.status()).toBe(200)
expect(await response.finished()).toBeNull()
await expect(page.getByRole('heading', { name: 'You have signed out' })).toBeVisible()
expect((await context.cookies()).filter((cookie) => SESSION_COOKIES.includes(cookie.name))).toEqual([])
await submitCredentials(page, RAVI)
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
const sessions = await tableSessions()
expect(sessions.map((row) => row.username)).toEqual([RAVI.username])
if (!sessions[0]?.id) throw new Error('Expected the new user session after logout')
await expectHttpOnlySession(page, context, sessions[0].id)
await page.reload()
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
await expectUserAndCloseNavigation(page, RAVI.name)
await expectHttpOnlySession(page, context, sessions[0].id)
} finally {
releaseLogout()
await page.unrouteAll({ behavior: 'wait' })
}
})
tableTest('[SEC-049] a sign-in after a logout that never reached the server uses only the new session @mobile', async ({ page, tableBased, diagnostics, context }) => {
void tableBased
diagnostics.allow('/qqq/v1/logout 503')
@@ -22,6 +22,7 @@ import { resolveFixtureClasspath } from '../../../../../scripts/acceptance-paths
export type AuthMode = 'MOCK' | 'OAUTH2' | 'AUTH_0' | 'FULLY_ANONYMOUS' | 'TABLE_BASED' | 'UNSUPPORTED'
export const SECURITY_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT ?? ACCEPTANCE_BACKEND_PORT + 10)
export const SECURITY_ESB_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_ESB_PORT ?? Number(process.env.QQQ_ACCEPTANCE_ESB_PORT ?? 61616) + 1)
export const IDP_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_IDP_PORT ?? ACCEPTANCE_FRONTEND_PORT + 10)
export const SECURITY_URL = `http://127.0.0.1:${SECURITY_PORT}`
export const IDP_URL = `http://127.0.0.1:${IDP_PORT}`
@@ -97,6 +98,7 @@ export async function startVariant(mode: AuthMode, options: { env?: Record<strin
`-Dqqq.sample.mockAuthentication=${mock}`,
`-Dqqq.sample.sharing=${mode === 'MOCK'}`,
`-Dqqq.sample.port=${SECURITY_PORT}`,
`-Dqqq.sample.esb.port=${SECURITY_ESB_PORT}`,
`-Dqqq.security.auth=${mode}`,
'-Duser.timezone=UTC',
'-Dqqq.javalin.frontend=next',
@@ -7,10 +7,11 @@
// Widget chrome and header controls: dropdowns, stored selections, reload, export, help, card chrome.
import type { Page } from '@playwright/test'
import { expectedDownloadFilename, nextDownloadAttribute } from '../../support/downloads'
import { expect, open, test } from '../../support/fixtures'
import { expectTouchReady } from '../../support/touch'
import {
chooseOption, downloadText, dropdown, dropdownOptions, expectLoaded, localeDay, requestParam, widget, widgetBody,
allowMissingExtension, chooseOption, downloadText, dropdown, dropdownOptions, expectLoaded, localeDay, requestParam, widget, widgetBody,
} from './widget-support'
const EXPECTED_CSV = '"Label","Value"\n"A,""B""",7\n"Beta","0"\n'
@@ -123,16 +124,18 @@ test('[WID-045] reload re-renders the widget from the backend @mobile', async ({
await expect(page.locator('[data-qqq-id="button-widget-reload-accHelp"]')).toHaveCount(0)
})
test('[WID-046] export downloads the payload csvData with Material quoting; no data shows a message @mobile', async ({ page, diagnostics }) => {
test('[WID-046] export downloads the payload csvData with Material quoting; no data shows a message @mobile', async ({ page, browserName, diagnostics }) => {
void diagnostics
await openControls(page)
await expectLoaded(page, 'accExport')
const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Export Owned Export' }).click()])
expect(download.suggestedFilename()).toMatch(/^Owned Export \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
const [download, downloadName] = await Promise.all([page.waitForEvent('download'), nextDownloadAttribute(page), page.getByRole('button', { name: 'Export Owned Export' }).click()])
expect(downloadName).toMatch(/^Owned Export \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(download.suggestedFilename()).toBe(expectedDownloadFilename(downloadName, browserName))
expect(await downloadText(download)).toBe(EXPECTED_CSV)
// the parent widget exports its own csvData too
const [parentDownload] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Export Owned Controls' }).click()])
expect(parentDownload.suggestedFilename()).toMatch(/^Owned Controls \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
const [parentDownload, parentDownloadName] = await Promise.all([page.waitForEvent('download'), nextDownloadAttribute(page), page.getByRole('button', { name: 'Export Owned Controls' }).click()])
expect(parentDownloadName).toMatch(/^Owned Controls \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(parentDownload.suggestedFilename()).toBe(expectedDownloadFilename(parentDownloadName, browserName))
expect(await downloadText(parentDownload)).toBe(EXPECTED_CSV)
// nothing to export: a message, and no download
let downloaded = false
@@ -198,8 +201,7 @@ test.describe('on a phone', () => {
test('[WID-041] plain (non-card) widgets drop the card chrome and show metadata footers; payload sublabels render @mobile', async ({ page, diagnostics }) => {
// the gallery deliberately includes a custom component whose bundle is missing
diagnostics.allow('/missing-extension.js 404')
diagnostics.allow('Failed to load resource: the server responded with a status of 404')
allowMissingExtension(page, diagnostics)
await openControls(page)
await expectLoaded(page, 'accPlain')
const plain = widget(page, 'accPlain')
@@ -8,13 +8,12 @@
// Owned widgets of the remaining canonical display types (WidgetsFixtures widgetGallery).
import { expect, open, test } from '../../support/fixtures'
import { expectTouchReady } from '../../support/touch'
import { chartTable, expectLoaded, widget, widgetPayload } from './widget-support'
import { allowMissingExtension, chartTable, expectLoaded, widget, widgetPayload } from './widget-support'
test.describe('widget gallery', () => {
test.beforeEach(async ({ page, diagnostics }) => {
// the gallery deliberately includes a custom component whose bundle is missing (WID-062)
diagnostics.allow('/missing-extension.js 404')
diagnostics.allow('Failed to load resource: the server responded with a status of 404')
allowMissingExtension(page, diagnostics)
await open(page, '/app/widgetGallery')
await expect(page.getByRole('heading', { level: 1, name: 'Widget Gallery' })).toBeVisible()
})
@@ -0,0 +1,21 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/** Whether Firefox's failed script is the gallery's deliberate HTTP 404. */
export function expectedMissingExtensionFailure(request: {
url: string
method: string
resourceType: string
status: number | undefined
errorText: string
}): boolean {
const url = new URL(request.url)
return url.protocol === 'http:' && url.hostname === '127.0.0.1'
&& url.pathname === '/missing-extension.js' && !url.search
&& request.method === 'GET' && request.resourceType === 'script'
&& request.status === 404 && request.errorText === 'NS_ERROR_DOM_NETWORK_ERR'
}
@@ -25,6 +25,7 @@ for (const hidePreview of [true, false]) {
})
await open(page, '/app/savedReport/1')
await expectLoaded(page, 'reportSetupWidget')
await expect(byId(page, 'report-column-reportSetupWidget-firstName')).toHaveText('First Name')
const preview = byId(page, 'filter-preview-reportSetupWidget')
const cells = preview.getByRole('gridcell').and(preview.locator('[data-qqq-id="grid-cell-firstName"]'))
if (hidePreview) await expect(preview).toHaveCount(0)
@@ -53,6 +54,7 @@ for (const hidePreview of [true, false]) {
const saved = await backend.sql('select query_filter_json from saved_report where id = 1')
expect(JSON.parse(saved[0].query_filter_json!).orderBys).toEqual([{ fieldName: 'id', isAscending: false }])
await expectLoaded(page, 'reportSetupWidget')
await expect(byId(page, 'report-column-reportSetupWidget-firstName')).toHaveText('First Name')
if (hidePreview) await expect(preview).toHaveCount(0)
else await expect(cells).toHaveText(people.map(row => row.first_name!).reverse())
await open(page, '/app/savedReport/1/edit')
@@ -7,10 +7,11 @@
// Reports: run through the basic report process in each format, input fields, permissions, navigation.
import type { Download, Page } from '@playwright/test'
import { expectedDownloadFilename } from '../../support/downloads'
import { expect, open, test } from '../../support/fixtures'
import { expectTouchReady } from '../../support/touch'
import { navigation } from '../security/support/ui'
import { downloadBytes, downloadText, parseCsv, readZip, sqlRows, xlsxRows } from './widget-support'
import { allowMissingExtension, downloadBytes, downloadText, parseCsv, readZip, sqlRows, xlsxRows } from './widget-support'
/** Runs a report in a format and returns the downloaded file. */
async function runReport(page: Page, reportName: string, format: 'CSV' | 'XLSX' | 'JSON'): Promise<Download> {
@@ -32,11 +33,13 @@ async function downloadResult(page: Page): Promise<Download> {
const PEOPLE = 'select id, first_name, last_name, email from person order by id'
test('[RPT-001] a table report downloads CSV with every row from the database @mobile', async ({ page, backend, diagnostics }) => {
test('[RPT-001] a table report downloads CSV with every row from the database @mobile', async ({ page, backend, browserName, diagnostics }) => {
void diagnostics
const people = await sqlRows(backend, PEOPLE)
const download = await runReport(page, 'accPersonReport', 'CSV')
expect(download.suggestedFilename()).toMatch(/^Owned Person Report - \d{4}-\d{2}-\d{2}-\d{4}\.csv$/)
const filename = await page.locator('[data-qqq-id="report-download-link-accPersonReport"]').getAttribute('download')
expect(filename).toMatch(/^Owned Person Report - \d{4}-\d{2}-\d{2}-\d{4}\.csv$/)
expect(download.suggestedFilename()).toBe(expectedDownloadFilename(filename!, browserName))
const rows = parseCsv(await downloadText(download))
expect(rows[0]).toEqual(['Id', 'First Name', 'Last Name', 'Email'])
expect(rows.slice(1)).toEqual(people.map((person) => [person.id, person.first_name, person.last_name, person.email]))
@@ -142,8 +145,7 @@ test('[RPT-011] a report without a process streams from the report route @mobile
})
test('[RPT-008] reports appear in app navigation and open their run page @mobile', async ({ page, diagnostics }) => {
diagnostics.allow('/missing-extension.js 404')
diagnostics.allow('Failed to load resource: the server responded with a status of 404')
allowMissingExtension(page, diagnostics)
await open(page, '/app/widgetGallery')
const nav = await navigation(page)
await nav.getByRole('button', { name: 'Expand Acceptance Reports' }).click()
@@ -7,6 +7,7 @@
// Material table and chart widget extras (QRun-IO/qqq#728): WidgetTableChartFixtures widgetTableCharts.
import type { Locator, Page } from '@playwright/test'
import { expectedDownloadFilename, nextDownloadAttribute } from '../../support/downloads'
import { expect, open, test } from '../../support/fixtures'
import { expectTouchReady } from '../../support/touch'
import { byId, downloadText, expectLoaded, widget, widgetPayload } from './widget-support'
@@ -149,14 +150,15 @@ test.describe('table and chart extras', () => {
await expectTouchReady(page, card)
})
test('[WID-068] export without csvData downloads the Material CSV of the columns and rows, without icon text @mobile', async ({ page, backend, diagnostics }) => {
test('[WID-068] export without csvData downloads the Material CSV of the columns and rows, without icon text @mobile', async ({ page, backend, browserName, diagnostics }) => {
void diagnostics
const payload = await widgetPayload(backend.api, 'accTableCells')
expect(payload.csvData ?? null).toBeNull()
const image = payload.rows[0].imageUrl
await expectLoaded(page, 'accTableCells')
const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Export Owned Typed Cells' }).click()])
expect(download.suggestedFilename()).toMatch(/^Owned Typed Cells \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
const [download, downloadName] = await Promise.all([page.waitForEvent('download'), nextDownloadAttribute(page), page.getByRole('button', { name: 'Export Owned Typed Cells' }).click()])
expect(downloadName).toMatch(/^Owned Typed Cells \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(download.suggestedFilename()).toBe(expectedDownloadFilename(downloadName, browserName))
const csv = await downloadText(download)
expect(csv).toBe([
'"Name","Count","Status","Product","Detail","Tooltip","Image URL","Image Label","Image Total","Image Total Type"',
@@ -167,7 +169,7 @@ test.describe('table and chart extras', () => {
expect(csv).not.toContain('open_in_new')
})
test('[WID-068] each table of a multi-table widget has its own export button and footer @mobile', async ({ page, diagnostics }) => {
test('[WID-068] each table of a multi-table widget has its own export button and footer @mobile', async ({ page, browserName, diagnostics }) => {
void diagnostics
await expectLoaded(page, 'accMultiTableExtras')
const card = widget(page, 'accMultiTableExtras')
@@ -180,11 +182,13 @@ test.describe('table and chart extras', () => {
await expect(second.locator('[data-qqq-id="table-footer-accMultiTableExtras-1"] b')).toHaveText('Owned second footer')
await expect(first.locator('[data-qqq-id^="table-footer-"]')).toHaveCount(1)
const [firstDownload] = await Promise.all([page.waitForEvent('download'), first.getByRole('button', { name: 'Export Owned first table' }).click()])
expect(firstDownload.suggestedFilename()).toMatch(/^Owned first table \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
const [firstDownload, firstDownloadName] = await Promise.all([page.waitForEvent('download'), nextDownloadAttribute(page), first.getByRole('button', { name: 'Export Owned first table' }).click()])
expect(firstDownloadName).toMatch(/^Owned first table \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(firstDownload.suggestedFilename()).toBe(expectedDownloadFilename(firstDownloadName, browserName))
expect(await downloadText(firstDownload)).toBe('"Name","Qty"\n"Owned alpha","1500"\n')
const [secondDownload] = await Promise.all([page.waitForEvent('download'), second.getByRole('button', { name: 'Export Owned second table' }).click()])
expect(secondDownload.suggestedFilename()).toMatch(/^Owned second table \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
const [secondDownload, secondDownloadName] = await Promise.all([page.waitForEvent('download'), nextDownloadAttribute(page), second.getByRole('button', { name: 'Export Owned second table' }).click()])
expect(secondDownloadName).toMatch(/^Owned second table \d{4}-\d{2}-\d{2} \d{4}\.csv$/)
expect(secondDownload.suggestedFilename()).toBe(expectedDownloadFilename(secondDownloadName, browserName))
expect(await downloadText(secondDownload)).toBe('"Owned CSV header"\n"Owned CSV value"\n')
await expectTouchReady(page, card)
})
@@ -8,9 +8,27 @@
// Helpers for the widgets & reports acceptance specs.
import { readFileSync } from 'node:fs'
import { inflateRawSync } from 'node:zlib'
import type { APIRequestContext, Download, Locator, Page } from '@playwright/test'
import type { APIRequestContext, Download, Locator, Page, Request } from '@playwright/test'
import { expect } from '../../support/fixtures'
import type { Backend } from '../../support/fixtures'
import { expectedMissingExtensionFailure } from './missing-extension'
import type { Backend, Diagnostics } from '../../support/fixtures'
/** Allows only the gallery's deliberate missing bundle, including Firefox's 404 script failure. */
export function allowMissingExtension(page: Page, diagnostics: Diagnostics) {
diagnostics.allow('/missing-extension.js 404')
diagnostics.allow('Failed to load resource: the server responded with a status of 404')
const statuses = new WeakMap<Request, number>()
page.on('response', (response) => statuses.set(response.request(), response.status()))
page.on('requestfailed', (request) => {
if (!expectedMissingExtensionFailure({
url: request.url(), method: request.method(), resourceType: request.resourceType(),
status: statuses.get(request), errorText: request.failure()?.errorText ?? '',
})) return
// Bind the allowance to the same Request's observed response and exact URL.
const message = `request: GET ${request.url()} NS_ERROR_DOM_NETWORK_ERR`
diagnostics.allow(new RegExp(`^${message.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`))
})
}
/** Reads rows whose selected columns are all non-null (fails otherwise). */
export async function sqlRows(backend: Backend, query: string): Promise<Array<Record<string, string>>> {
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import type { Page } from '@playwright/test'
/**
* Linux WebKit's pinned libsoup 3.6.6 replaces filename spaces with underscores.
* Assert the application's original name separately; only the browser suggestion changes.
* https://github.com/GNOME/libsoup/commit/9e1570c914fa6638913cec4e9dbd8aff11b8dab4
*/
export function expectedDownloadFilename(filename: string, browserName: string): string {
return process.platform === 'linux' && browserName === 'webkit' ? filename.replace(/ /g, '_') : filename
}
/** Reads the application's transient download attribute without changing the click. */
export function nextDownloadAttribute(page: Page): Promise<string> {
return page.evaluate(() => new Promise<string>((resolve) => {
const onClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target.closest('a[download]') : null
if (!(target instanceof HTMLAnchorElement)) return
document.removeEventListener('click', onClick, true)
resolve(target.download)
}
document.addEventListener('click', onClick, true)
}))
}
+56 -21
View File
@@ -6,11 +6,12 @@
*/
import { randomUUID } from 'node:crypto'
import { test as base, expect, type APIRequestContext, type Page, type Response } from '@playwright/test'
import { test as base, expect, type APIRequestContext, type Page, type Request, type Response } from '@playwright/test'
import { interruptedLegacyFont, type FontRequestOutcome } from './font-diagnostics'
import { ACCEPTANCE_BACKEND_PORT, ACCEPTANCE_BACKEND_URL, ACCEPTANCE_UI_URL } from './ports'
/** Personas defined by tests/acceptance/fixture/AcceptanceSampleServer.java. */
export type Persona = 'admin' | 'viewer' | 'noPets' | 'noProcesses' | 'noApps' | 'expired'
export type Persona = 'admin' | 'viewer' | 'noPets' | 'noProcesses' | 'noApps' | 'expired' | 'noPersonRead' | 'noEsbView' | 'noEsbOperate' | 'noEsbDelete' | 'noSyncPerson'
/** Sample sharing-demo identities (owners of the seeded saved view and report). */
export type SampleUser = 'alice' | 'bob' | 'casey'
@@ -40,6 +41,10 @@ export interface Backend {
/** Direct backend HTTP call with this test's session, for independent enforcement checks. */
api: APIRequestContext
sessionId: string
/** Make the owned subscriber fail so a real publication goes through dead-lettering. */
failEsbSync: (fail: boolean) => Promise<void>
/** Actual Artemis counts on the owned topic subscription, for pause readiness. */
esbSubscriptionState: () => Promise<{ consumerCount: number; deliveringCount: number; messageCount: number }>
setPersona: (persona: Persona, user?: SampleUser) => Promise<void>
/** Marks the seeded Alice People saved view as a counted quick view. */
seedQuickView: () => Promise<void>
@@ -62,7 +67,7 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
persona: ['admin', { option: true }],
user: ['alice', { option: true }],
backend: async ({ context, persona, user, playwright }, use) => {
backend: async ({ context, persona, user, playwright }, provide) => {
const sessionId = randomUUID()
await control('reset', {})
await control('persona', { sessionId, persona, user })
@@ -71,19 +76,25 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
const api = await playwright.request.newContext({
baseURL: ACCEPTANCE_BACKEND_URL, extraHTTPHeaders: { Cookie: `sessionId=${sessionId}` },
})
await use({
sessionId,
api,
sql: async (query) => (await control('sql', { query })).rows,
seedQuickView: async () => { await control('quick-view', {}) },
seedStaleView: async () => { await control('stale-view', {}) },
enableTheme: async () => { await control('theme', {}) },
setPersona: async (next, nextUser = user) => { await control('persona', { sessionId, persona: next, user: nextUser }) },
})
await api.dispose()
try {
await provide({
sessionId,
failEsbSync: async (fail) => { await control('esb/failure', { fail }) },
esbSubscriptionState: async () => control('esb/subscription-state', {}),
api,
sql: async (query) => (await control('sql', { query })).rows,
seedQuickView: async () => { await control('quick-view', {}) },
seedStaleView: async () => { await control('stale-view', {}) },
enableTheme: async () => { await control('theme', {}) },
setPersona: async (next, nextUser = user) => { await control('persona', { sessionId, persona: next, user: nextUser }) },
})
} finally {
await api.dispose()
await control('esb/stop', {})
}
},
diagnostics: async ({ page }, use, testInfo) => {
diagnostics: async ({ page }, provide, testInfo) => {
const allowed: (string | RegExp)[] = []
const matches = (text: string) => allowed.some((pattern) => typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text))
const diagnostics: Diagnostics = { pageErrors: [], consoleErrors: [], failedRequests: [], cspViolations: [], interruptedFetches: [], legacyRequests: [], allow: (pattern) => { allowed.push(pattern) } }
@@ -117,6 +128,14 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
// navigation - still fails the test.
const documentHosts = new Set<string>()
const navigations: number[] = []
let currentDocument = 0
const fontDocuments = new WeakMap<Request, number>()
const fontOutcomes: FontRequestOutcome[] = []
const fontReports: { text: string; at: number }[] = []
page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) currentDocument += 1 })
page.on('request', (request) => {
if (request.resourceType() === 'font') fontDocuments.set(request, currentDocument)
})
// The UI runs on the v1 API only (QRun-IO/qqq#699): any page request to an unversioned API
// route of a QQQ server fails the test (Node-side backend.api calls are not page requests).
const legacyRoute = /^\/(data|processes|widget|possibleValues|download|reports|metaData|manageSession|logout)(\/|$)/
@@ -154,19 +173,19 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
const truncatedImage = /^\[JavaScript Error: "Image corrupt or truncated\." \{file: "([^"]+)"/
const truncatedImages: { text: string; url: string }[] = []
page.on('pageerror', (error) => report(error.message, () => diagnostics.pageErrors.push(error.message)))
// The table developer view asks GET /qqq/v1/esb/table/{table} for its ESB section and treats a
// 404 as "nothing to show" (QRun-IO/qqq#739). The acceptance sample has no qqq-esb module, so
// that one 404, and the browser's console report of it, are expected; nothing else on the route is.
const esbTableProbe = (url: string) => /^\/qqq\/v1\/esb\/table\/[^/]+$/.test(new URL(url, 'http://local').pathname)
page.on('console', (message) => {
if (message.type() !== 'error') return
if (/status of 404/.test(message.text()) && esbTableProbe(message.location().url || '/')) return
if (message.text().includes('downloadable font: download failed') && message.text().includes('QQQ Legacy Material Icons')) {
fontReports.push({ text: message.text(), at: performance.now() })
return
}
const image = truncatedImage.exec(message.text())?.[1]
if (image) truncatedImages.push({ text: message.text(), url: image })
else report(message.text(), () => diagnostics.consoleErrors.push(message.text()))
})
page.on('requestfailed', (request) => {
const failure = request.failure()?.errorText ?? ''
if (request.resourceType() === 'font') fontOutcomes.push({ url: request.url(), outcome: failure, at: performance.now(), document: fontDocuments.get(request) ?? currentDocument })
// Navigation-cancelled background reads are not application failures.
if (/ERR_ABORTED|NS_BINDING_ABORTED|cancelled/i.test(failure)) return
const text = `${request.method()} ${request.url()} ${failure}`
@@ -180,10 +199,13 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
}
}
page.on('response', (response: Response) => {
if (response.status() === 404 && response.request().method() === 'GET' && esbTableProbe(response.url())) return
const request = response.request()
if (request.resourceType() === 'font' && response.status() === 200) {
fontOutcomes.push({ url: request.url(), outcome: 'HTTP 200', at: performance.now(), document: fontDocuments.get(request) ?? currentDocument })
}
if (response.status() >= 400) diagnostics.failedRequests.push(`${response.request().method()} ${new URL(response.url()).pathname} ${response.status()}`)
})
await use(diagnostics)
await provide(diagnostics)
// One round trip delivers violation reports still queued in the page.
if (!page.isClosed()) await page.evaluate(() => 0).catch(() => undefined)
classifyInterruptedRouteReports()
@@ -197,6 +219,19 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
if (decodes) diagnostics.interruptedFetches.push(text)
else diagnostics.consoleErrors.push(text)
}
for (const { text, at } of fontReports) {
const origin = !page.isClosed() ? new URL(page.url()).origin : ''
const interrupted = interruptedLegacyFont({ text, at, origin, currentDocument, navigations, requests: fontOutcomes })
// A proven old-document font-loader cancellation is retained only after the final document loads and
// decodes the actual legacy font. Missing, corrupt, CSP-blocked or unrelated fonts fail.
const loaded = interrupted && !page.isClosed() && await page.evaluate(async () => {
const faces = await document.fonts.load('24px "QQQ Legacy Material Icons"', '\ue88a')
return faces.length > 0 && faces.every((face) => face.status === 'loaded')
&& document.fonts.check('24px "QQQ Legacy Material Icons"', '\ue88a')
}).catch(() => false)
if (loaded) diagnostics.interruptedFetches.push(text)
else diagnostics.consoleErrors.push(text)
}
const unexpected = [
...diagnostics.pageErrors.map((text) => `pageerror: ${text}`),
...diagnostics.consoleErrors.map((text) => `console: ${text}`),
@@ -0,0 +1,33 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { describe, expect, it } from 'vitest'
import { interruptedLegacyFont } from './font-diagnostics'
const url = 'http://127.0.0.1:18861/fonts/material-icons/MaterialIcons-Regular.ttf'
const text = `[JavaScript Error: "downloadable font: download failed (font-family: "QQQ Legacy Material Icons" style:normal weight:400 stretch:100 src index:0): status=2152398850 source: ${url}"]`
const aborted = { url, at: 1100, document: 1, outcome: 'NS_BINDING_ABORTED' }
const context = { text, at: 1105, origin: 'http://127.0.0.1:18861', currentDocument: 2, navigations: [1050], requests: [aborted] }
describe('legacy font cancellation evidence', () => {
it('matches a same-origin font cancellation from a superseded document near navigation', () => {
expect(interruptedLegacyFont(context)).toBe(url)
})
it('recognizes the exact font-loader cancellation after successful transport in the old document', () => {
expect(interruptedLegacyFont({ ...context, requests: [{ ...aborted, outcome: 'HTTP 200' }] })).toBe(url)
})
it.each([
{ text: text.replace('2152398850', '2152398851') },
{ origin: 'https://unrelated.invalid' },
{ currentDocument: 1 },
{ navigations: [4000] },
{ requests: [] },
{ requests: [{ ...aborted, outcome: 'HTTP 404' }] },
{ requests: [{ ...aborted, at: 9000 }] },
])('retains unexplained errors: %j', (change) => {
expect(interruptedLegacyFont({ ...context, ...change })).toBeNull()
})
})
@@ -0,0 +1,20 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
export interface FontRequestOutcome { url: string; at: number; document: number; outcome: string }
/** Identifies a font abort only when request and document lifecycle evidence agree. */
export function interruptedLegacyFont({ text, at, origin, currentDocument, navigations, requests }: {
text: string; at: number; origin: string; currentDocument: number; navigations: number[]; requests: FontRequestOutcome[]
}): string | null {
const source = /^\[JavaScript Error: "downloadable font: download failed \(font-family: "QQQ Legacy Material Icons".*\): status=2152398850 source: (https?:\/\/[^"\s]+)"\]$/.exec(text)?.[1]
if (!source) return null
const url = new URL(source)
if (url.origin !== origin || url.pathname !== '/fonts/material-icons/MaterialIcons-Regular.ttf') return null
const oldDocumentRequest = requests.some((request) => request.url === source && ['NS_BINDING_ABORTED', 'HTTP 200'].includes(request.outcome)
&& request.document < currentDocument && Math.abs(request.at - at) < 1000)
return oldDocumentRequest && navigations.some((navigation) => Math.abs(navigation - at) < 1000) ? source : null
}
@@ -0,0 +1,31 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { describe, expect, it } from 'vitest'
import { expectedMissingExtensionFailure } from '../specs/widgets/missing-extension'
const missing = {
url: 'http://127.0.0.1:46487/missing-extension.js', method: 'GET',
resourceType: 'script', status: 404, errorText: 'NS_ERROR_DOM_NETWORK_ERR',
}
describe('the deliberate missing extension', () => {
it('recognizes the failed script only with its observed 404', () => {
expect(expectedMissingExtensionFailure(missing)).toBe(true)
})
it.each([
{ status: undefined }, { status: 200 }, { status: 403 }, { status: 500 },
{ url: 'http://127.0.0.1:46487/owned-extension.js' },
{ url: 'https://example.com/missing-extension.js' },
{ url: 'http://127.0.0.1:46487/missing-extension.js?unexpected=1' },
{ method: 'POST' }, { resourceType: 'fetch' },
{ errorText: 'NS_ERROR_CONNECTION_REFUSED' },
])('keeps an unrelated or unproven failure actionable: %j', (changed) => {
expect(expectedMissingExtensionFailure({ ...missing, ...changed })).toBe(false)
})
})
@@ -0,0 +1,42 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { afterEach, describe, expect, it } from 'vitest'
import { visibleToastObservation } from './toast-observation'
function element(html: string, top: number, height: number) {
const parent = document.createElement('div')
parent.innerHTML = html
const node = parent.firstElementChild as HTMLElement
node.getBoundingClientRect = () => ({ x: 0, y: top, top, bottom: top + height, left: 0, right: 100, width: 100, height, toJSON: () => ({}) })
document.body.append(node)
return node
}
afterEach(() => { document.body.innerHTML = '' })
describe('browser-side toast observation', () => {
it('requires the exact visible message', () => {
expect(visibleToastObservation({ message: 'Saved', belowHeader: false })).toBeNull()
const toast = element('<li data-sonner-toast>Not saved</li>', 140, 50)
expect(visibleToastObservation({ message: 'Saved', belowHeader: false })).toBeNull()
toast.textContent = 'Saved'
toast.style.visibility = 'hidden'
expect(visibleToastObservation({ message: 'Saved', belowHeader: false })).toBeNull()
toast.style.visibility = 'visible'
expect(visibleToastObservation({ message: 'Saved', belowHeader: false })).toEqual(['Saved'])
})
it('waits for a toast to sit below the header and returns duplicate messages for the count assertion', () => {
element('<header data-qqq-id="header"></header>', 0, 117)
const toast = element('<li data-sonner-toast>Created</li>', 20, 50)
expect(visibleToastObservation({ message: 'Created', belowHeader: true })).toBeNull()
toast.getBoundingClientRect = () => ({ x: 0, y: 140, top: 140, bottom: 190, left: 0, right: 100, width: 100, height: 50, toJSON: () => ({}) })
element('<li data-sonner-toast>Something went wrong</li>', 200, 50)
expect(visibleToastObservation({ message: 'Created', belowHeader: true })).toEqual(['Created', 'Something went wrong'])
})
})
@@ -0,0 +1,21 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/** Reads notification visibility and placement together inside the browser. */
export function visibleToastObservation({ message, belowHeader }: { message: string; belowHeader: boolean }): string[] | null {
const notifications = [...document.querySelectorAll<HTMLElement>('[data-sonner-toast]')]
const toast = notifications.find((node) => node.textContent?.trim() === message)
if (!toast) return null
const bounds = toast.getBoundingClientRect()
const style = getComputedStyle(toast)
if (!bounds.width || !bounds.height || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return null
if (belowHeader) {
const header = document.querySelector('[data-qqq-id="header"]')
if (!header || bounds.top < header.getBoundingClientRect().bottom) return null
}
return notifications.map((node) => node.textContent?.trim() ?? '')
}