Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff90f1a63 | ||
|
|
50a935a4a5 | ||
|
|
40e987d4d0 | ||
|
|
71f5a39b09 | ||
|
|
45f99f27ce | ||
|
|
d17e880e79 | ||
|
|
f8fdb8e997 | ||
|
|
ad154b5b56 | ||
|
|
c6a58933a6 | ||
|
|
26e3a0d004 | ||
|
|
c9d7fcd393 | ||
|
|
b3e1ddda8b | ||
|
|
8d4668cdab | ||
|
|
254a0a2050 | ||
|
|
52cc038fa6 | ||
|
|
6090f2f9b4 | ||
|
|
32a9560970 | ||
|
|
96085c72e2 | ||
|
|
c029ad2a70 | ||
|
|
32a3b281ff | ||
|
|
a0a18cfa76 |
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+28
-38
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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\/?$/)
|
||||
|
||||
@@ -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>>> {
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
@@ -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() ?? '')
|
||||
}
|
||||
Reference in new issue
Block a user