Compare commits

...
Author SHA1 Message Date
james.maesandCodex e97dab74f5 docs(frontend): distinguish RC37 tag and corrected CI
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-29 09:56:41 -05:00
james.maesandGPT-6 c8f79847f3 fix(ci): bound browser dependency downloads
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 11:17:48 -05:00
james.maesandGPT-6 4d28309be7 docs: record verified RC37 public artifacts
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 10:06:24 -05:00
james.maesandGPT-6 13abaa795d fix: patch frontend RC development tool advisories
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 08:48:27 -05:00
james.maesandGPT-6 2f556afadf release: prepare RC37 collaboration frontend candidate
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 08:44:24 -05:00
james.maesandCodex dce92cfba1 test: verify marking mentions read refreshes My Work
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 19:30:44 -05:00
james.maesandCodex 8d372142f8 test: wait for help results before search navigation
Retain the destination and response assertions while synchronizing the click with asynchronous help content. The failed Firefox trace shows help results moving the target during the pointer gesture.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-21 17:03:13 -05:00
james.maesandCodex 0f0ebc524f fix: retain Auth0 display profile with canonical presence identity
Keep the backend user ID without dropping provider display fields on sign-in or reload. Do not reuse a different user's cached profile. Reproduced callback/reload failures; full 3664-test suite, TypeScript and lint pass. Real mobile flow assertions pass locally, with Windows export diagnostics tracked separately; Linux acceptance remains required.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 16:26:33 -05:00
james.maesandCodex dad2a79824 fix: patch frontend dependencies for collaboration candidate
Update follow-redirects and Vite without unrelated package upgrades. Await the real lazy widget import in its fallback test. Full 3660-test suite, TypeScript and touched-test lint pass.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 15:56:46 -05:00
james.maesandCodex de5c1bd066 feat: integrate issued collaboration presence leases
Use server-issued presence handles, preserve drafts and unavailable states, and invalidate scoped realtime hints. Align the bundled collaboration 1.3 contract and retain focused/browser acceptance coverage. Carry forward current main CI and navigation test fixes.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 15:48:31 -05:00
35 changed files with 5194 additions and 3664 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.12
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
orb_version: '0.8'
filters:
branches:
ignore: /.*/
+3 -3
View File
@@ -24,11 +24,11 @@ jobs:
ci:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@eeb76914ad49fcdb2197529481de55d1e35a008a
acceptance:
if: github.event_name != 'pull_request' || !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action) || github.event.label.name == 'full-acceptance'
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@eeb76914ad49fcdb2197529481de55d1e35a008a
with:
qqq_ref: ${{ inputs.qqq_ref || '' }}
dashboard-image:
@@ -38,4 +38,4 @@ jobs:
permissions:
contents: read
packages: write
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@eeb76914ad49fcdb2197529481de55d1e35a008a
+2
View File
@@ -10,6 +10,8 @@
# next.js
/.next/
/.next-collaboration/
/.next-presence-live/
/out/
# production
+1 -1
View File
@@ -1,3 +1,3 @@
profile: frontend-next
orb_version: 0.7.12
orb_version: '0.8'
default_branch: main
+138
View File
@@ -0,0 +1,138 @@
# Next UI 1.0.0-RC.37 — published collaboration candidate
This candidate is published. Artifacts are
`com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.37` and
`ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.37`. RC.36 remains immutable.
Publication does not deploy Kof22 or authorize final 1.0.
## Source and changes
Prepared from collaboration source `dce92cfba1f02f7969c19b59d4442f5488b3821e`.
The RC preparation changes the package and Maven versions together and retains
main's managed orb 0.8 publication configuration. This source
includes the previously tested collaboration source
`8d372142f8af0c41a75a353edc6d150c33ccf613` plus a My Work browser regression test.
- Presence opens server-issued leases and renews them through
`/presence/leases` and `/presence/leases/renew`. Record/table viewers and
editors receive updates through the existing SSE/HTTP transport.
- Authentication retains the Auth0 display profile while using canonical
backend presence identity. Departure, expired leases and reconnect behavior
remain covered by the collaboration test suite.
- Marking a mention read refreshes My Work; the additional browser test checks
that behavior.
- Changes since RC.36 also include assistant conversation navigation, unread
state and attachment recovery, startup branding, shared HTML sanitization,
dependency fixes and centralized CI. This is not a presence-only diff from
RC.36.
- Development dependencies are aligned on Storybook 10.6.2 and Vitest 4.1.12
(including `@vitest/mocker`) for
[GHSA-f2ph-cmg5-wxc8](https://github.com/storybookjs/storybook/security/advisories/GHSA-f2ph-cmg5-wxc8)
and [GHSA-xp26-3454-mjgp](https://github.com/vitest-dev/vitest/security/advisories/GHSA-xp26-3454-mjgp).
These advisories affect development-server tooling; they do not describe a
Storybook server in the static dashboard package. Integration is tracked in
[QQQ #1327](https://github.com/QRun-IO/qqq/issues/1327).
## Backend pairing
Use a backend implementing the issued-lease collaboration API. The verified
earlier pair is QQQ `4.1.0-collab-33464d37e-SNAPSHOT` with collaboration
`0.1.0-collab-8f348da-SNAPSHOT` and Redis `0.1.0-redis-7fe5492-SNAPSHOT`.
The selectable-field follow-up pair is QQQ
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT` with collaboration
`0.1.0-field-values-1196-ecff347-SNAPSHOT`; that pair's independent API probe
does not establish packaged frontend acceptance.
Configure authenticated scope, Redis namespaces and lifecycle, SSE delivery and
connection affinity in the host. Presence is advisory; stale-save protection
still requires backend record versions. Kof22 PR338's RC.36 frontend must be
updated only after this artifact is published and independently verified.
## Qualification and release gates
- [x] Local typecheck and all 3,664 unit tests pass. Lint passes with six
warnings and no errors; license headers pass.
- [x] Canonical Circle caller and immutable GitHub worker bridge validate locally.
- [x] Patched toolchain passes TypeScript, all 3,664 unit tests and Storybook
static build (bundle-size warnings remain).
- [x] Exact RC source passes hosted CI and full browser acceptance.
- [x] Local static export builds and packages under `next-dashboard/`; development
mock worker is excluded.
- [x] Fresh immutable RC tag publishes matching Maven and image versions.
- [x] Public JAR/POM checksums and image digest are recorded.
- [x] Browser acceptance runs against the downloaded frontend JAR and the
selected published backend pair.
- [ ] Kof22 integration and isolated staging qualification are complete before
production rollout is proposed.
Earlier source acceptance run
[39683690943](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/39683690943)
is supporting evidence, not qualification of this RC package. The versioned tag
triggers Maven and container publication; the inspected workflows contain no
host deployment step.
Local Java 21 Maven packaging passed on October 26. ZIP inspection confirmed
the home and dynamic record fallback pages, both issued-lease endpoint strings
in the packaged JavaScript, and exclusion of `mockServiceWorker.js`. This is
package-content evidence, not a substitute for browser protocol acceptance.
## Published artifact evidence
Signed tag `v1.0.0-RC.37` points to
`13abaa795d71f0a2213409fb910003920b82f3c3`. Source acceptance
[40333860200](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/40333860200)
passed all 18 real-backend browser shards and the aggregate gate. One shard
required a targeted retry after a 40-minute system-package download timeout;
its initial attempt never reached browser tests. CodeQL, unit/coverage,
collaboration browser profiles and both image architectures passed.
Circle publisher 758 and node build 759 passed. The tag's separate acceptance
run [40342829221](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/40342829221)
published its images successfully, but its tablet shard and one targeted retry
both timed out after 40 minutes downloading Ubuntu browser dependencies, before
browser tests started. The aggregate gate therefore did not pass. Candidate CI
now pins verified orb commit `eeb76914ad49fcdb2197529481de55d1e35a008a`, whose
browser installation steps use bounded APT retries/timeouts and the official
HTTPS mirror fallbacks. This changes candidate CI only; the signed RC tag and
published artifacts remain unchanged. Updated-worker qualification
[40357830356](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/40357830356)
passed on candidate commit `c8f79847f3174425f02538c3c1b345eff86fda53`: all 18
real-backend browser shards and the aggregate gate succeeded, with no failed
jobs. This qualifies the corrected candidate workflow; it does not change the
failed historical tag run or replace the earlier source acceptance evidence.
| Public artifact | SHA-256 / OCI digest |
| --- | --- |
| Maven POM | `4761d2fd07f60da226fb179837b053a30a5a198e8238189d3912fcb3a69849f9` |
| Maven JAR | `c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab` |
| Multi-platform image index | `sha256:0b0fa9906eed60df1d23f28390888708587361a168b20fef92f49e6090723822` |
| Linux AMD64 image | `sha256:a6e5ee0947b5e12cbf821741a2a72fdac7c705d1276f59bb9497807cdd020f75` |
| Linux ARM64 image | `sha256:91999495b5958a2b1d67a43fd768c274690fea3eb3e79eaaab0e5924c8335eb4` |
The POM and JAR were downloaded directly from Maven Central; both match the
repository's SHA-512 checksums. Coordinate and JAR resource checks passed.
Repository checksums establish transfer integrity, not signature identity.
The immutable AMD64 image served `/app/person` with HTTP 200 in a disposable
container; ARM64 has hosted build verification, not a local runtime claim.
The downloaded public JAR passed Chromium acceptance with six mock identities
across two independent JVMs using the published selectable-field backend pair
above: each browser saw the other five users; all four viewers saw exactly two
editors; editor field focus, retained drafts, table projection and departure
passed. Redis connection/subscription metrics, persisted framework UP health
results and standard health-record navigation also passed. There were no browser
page errors, and all test-owned processes and Redis resources were cleaned up.
The hosts use independently seeded memory records and mock authentication;
these checks do not prove production identity-provider or shared-database save
behavior. Kof22 staging adoption remains outstanding.
## Limits and rollback
This branch does not include separate frontend PR194/195 or the independent
backend attachment, My Work and Redis-recovery follow-ups. Those remain tracked
in the QQQ project.
If packaged acceptance fails, withhold adoption and fix the candidate. After
deployment, restore a previously qualified frontend/backend pair together;
RC.36 must not be substituted against a backend requiring issued leases.
No host data migration, production deployment or GA approval is implied.
+3
View File
@@ -1,5 +1,8 @@
# Next UI — unreleased
[RC37 collaboration candidate](1.0.0-RC.37.md) records the published issued-lease
frontend, artifact receipts, backend pairing and outstanding host qualification.
[RC36 preparation](1.0.0-RC.36.md) collects every change merged since [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35). RC35 is published and immutable. Changes merged after the RC36 preparation commit are listed below. 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).
## Features
+2
View File
@@ -20,6 +20,8 @@ const BACKEND_PREFIXES: string[] = backendPrefixes
const nextConfig: NextConfig = {
reactStrictMode: true,
// Allow acceptance servers to run without sharing the developer server's lock/cache.
distDir: process.env.QQQ_NEXT_DIST_DIR || '.next',
// Reported to the backend as frontendVersion and shown on the developer page.
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
...(exportBuild
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.36",
"version": "1.0.0-RC.37",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
@@ -76,9 +76,9 @@
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@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",
"@storybook/addon-docs": "10.6.2",
"@storybook/nextjs-vite": "10.6.2",
"@storybook/react": "10.6.2",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -89,8 +89,8 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/ui": "^4.1.12",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-next": "16.3.6",
@@ -101,10 +101,11 @@
"msw": "^2.12.10",
"postcss": "^8.5.28",
"prettier": "^3.8.1",
"storybook": "10.6.0",
"storybook": "10.6.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
"vite": "7.3.7",
"vitest": "^4.1.12"
},
"msw": {
"workerDirectory": [
@@ -116,7 +117,8 @@
"serialize-javascript": ">=7.0.3",
"@humanfs/node": ">=0.16.8",
"@babel/core": ">=7.29.6 <8",
"source-map-js": ">=1.2.2"
"source-map-js": ">=1.2.2",
"follow-redirects@<1.16.1": "1.16.1"
},
"ignoredOptionalDependencies": [
"electron"
+1 -1
View File
@@ -34,7 +34,7 @@ export default defineConfig({
webServer: {
command: `pnpm dev --port ${port}`,
url: baseURL,
env: { NEXT_PUBLIC_MOCK_API: 'true' },
env: { NEXT_PUBLIC_MOCK_API: 'true', QQQ_NEXT_DIST_DIR: '.next-collaboration' },
reuseExistingServer: false,
timeout: 120_000,
},
+147 -151
View File
@@ -9,6 +9,7 @@ overrides:
'@humanfs/node': '>=0.16.8'
'@babel/core': '>=7.29.6 <8'
source-map-js: '>=1.2.2'
follow-redirects@<1.16.1: 1.16.1
importers:
@@ -151,14 +152,14 @@ importers:
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))
specifier: 10.6.2
version: 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.2(@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.8(@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.64.6)(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)(yaml@2.9.1))
specifier: 10.6.2
version: 10.6.2(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@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.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@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)
specifier: 10.6.2
version: 10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
'@tailwindcss/postcss':
specifier: ^4.2.1
version: 4.2.1
@@ -188,13 +189,13 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^5.1.4
version: 5.1.4(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
version: 5.1.4(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@vitest/coverage-v8':
specifier: ^4.1.11
version: 4.1.11(vitest@4.1.11)
specifier: ^4.1.12
version: 4.1.12(vitest@4.1.12)
'@vitest/ui':
specifier: ^4.1.11
version: 4.1.11(vitest@4.1.11)
specifier: ^4.1.12
version: 4.1.12(vitest@4.1.12)
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.28)
@@ -226,17 +227,20 @@ importers:
specifier: ^3.8.1
version: 3.8.1
storybook:
specifier: 10.6.0
version: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
specifier: 10.6.2
version: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
typescript:
specifier: ^5.9.3
version: 5.9.3
vite:
specifier: 7.3.7
version: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1))
specifier: ^4.1.12
version: 4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
packages:
@@ -1850,19 +1854,19 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@storybook/addon-docs@10.6.0':
resolution: {integrity: sha512-Fk3rUQWeGyCfL170OkmRp6Ruyv7MHPb/xrKa6YNk7FlGmlVLi5tKzh2T/0J13vWEy8wiu2MV3IBu8iRsaUR+Cw==}
'@storybook/addon-docs@10.6.2':
resolution: {integrity: sha512-f7Rv/rzX1sk4lUDTeoIRkXWcWvNwLv7vBnfpFGb3f88MliB+M4ZekGKNiKtrmi44uLFt52gzwwPzj2eGDu0DQQ==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.6.0
storybook: ^10.6.2
peerDependenciesMeta:
'@types/react':
optional: true
'@storybook/builder-vite@10.6.0':
resolution: {integrity: sha512-7zt8tYB6dnSO8fbjUz91MHexGDpf9HrLlA3FAmlVb+2ZL3eWUs499L3I6BDUymcWu7oW5WCBJlB8ERj8ZLLzig==}
'@storybook/builder-vite@10.6.2':
resolution: {integrity: sha512-SzeQEGjvybdhP2coF4aIX6JX/7rZTrQCpRU3vAEO/R98Vr3xM8jdg7a3nvt91TeF/P8iWqljLIpaDjTdk2r/sQ==}
peerDependencies:
storybook: ^10.6.0
storybook: ^10.6.2
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@storybook/global@5.0.0':
@@ -1873,15 +1877,15 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/nextjs-vite@10.6.0':
resolution: {integrity: sha512-TTXZsOUAXyezvZt+ICuIpYjzOkUXfEidUCbd/nnzczHaoxLWvVqwTzVHt9To7zsXWSfZFOxHQV9F4Uuzh74aKw==}
'@storybook/nextjs-vite@10.6.2':
resolution: {integrity: sha512-19H8L6FG5b8IKLeVkGMKTBMm1vag38QYMs/DZKn1tfwyUDOhr1XfZcZ+3PjlixzjgprIfgCz1JCRS7LxUFLyjA==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
next: ^14.1.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.6.0
storybook: ^10.6.2
typescript: '*'
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
@@ -1892,40 +1896,40 @@ packages:
typescript:
optional: true
'@storybook/react-dom-shim@10.6.0':
resolution: {integrity: sha512-ZE0l4xL6d00rKFeJhRDuh0t3r+/2ffhx47lIyaSWrTD5kuM3nDjKDxCIWwX4E3gLV+oNX8E1prXRMuEWJUJwJg==}
'@storybook/react-dom-shim@10.6.2':
resolution: {integrity: sha512-k0262Vqxjk3wHeg3g/QO/ENDrkC7kkbuwsFSXOdYr7Sl888tsF1DAS7uQ/gr+s/BRz1FLYRX7wBY3smEUV4MJg==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.6.0
storybook: ^10.6.2
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@storybook/react-vite@10.6.0':
resolution: {integrity: sha512-nc1dqnmjGOcDJvPCkAn4Hmu1gi7dNHHoibNmlBp0d1adosejELZxuNjHBkPl/KxZjbzP3jljQHnHB/guxd8e2g==}
'@storybook/react-vite@10.6.2':
resolution: {integrity: sha512-RfYSW9eefxRXk7IXKBTBkBYAspHKaxNVBR7mlXQAt+Z2bJZzj+S21mv6kBcpFIfAIT9wSIq8xMg0RbeR7CneVQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.6.0
storybook: ^10.6.2
typescript: '>= 4.9.x'
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
typescript:
optional: true
'@storybook/react@10.6.0':
resolution: {integrity: sha512-0xfYYPRHcr5ta1hmXJCSezH4JGuUHJ+xVF9f1j+Y/MWEMOQE4wnaKWKsk/HLrDjZkT5hKfvmogt9b0yduP6zEQ==}
'@storybook/react@10.6.2':
resolution: {integrity: sha512-kNWUU6Q8TrLmEqAzYmNYhC9A3i3kbjFxqIcLL3eu+o4gDu0afp1Of6Sy6jIF2qPGH05A3r6FWMzD03y4Mm5xRw==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.6.0
storybook: ^10.6.2
typescript: '>= 4.9.x'
peerDependenciesMeta:
'@types/react':
@@ -2399,11 +2403,11 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/coverage-v8@4.1.11':
resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==}
'@vitest/coverage-v8@4.1.12':
resolution: {integrity: sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==}
peerDependencies:
'@vitest/browser': 4.1.11
vitest: 4.1.11
'@vitest/browser': 4.1.12
vitest: 4.1.12
peerDependenciesMeta:
'@vitest/browser':
optional: true
@@ -2411,11 +2415,11 @@ packages:
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
'@vitest/expect@4.1.12':
resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==}
'@vitest/mocker@4.1.11':
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
'@vitest/mocker@4.1.12':
resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2428,31 +2432,31 @@ packages:
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/pretty-format@4.1.11':
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
'@vitest/pretty-format@4.1.12':
resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==}
'@vitest/runner@4.1.11':
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
'@vitest/runner@4.1.12':
resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==}
'@vitest/snapshot@4.1.11':
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
'@vitest/snapshot@4.1.12':
resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/spy@4.1.11':
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
'@vitest/spy@4.1.12':
resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==}
'@vitest/ui@4.1.11':
resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==}
'@vitest/ui@4.1.12':
resolution: {integrity: sha512-eYUTHtsp52Fu6YGUw9T9173bba7YUr9j/Xj9CdhUaOb8AppLKcknHUaiZQeqJ8MiJ8C2VyKiZuUGSKdvPIKQKA==}
peerDependencies:
vitest: 4.1.11
vitest: 4.1.12
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
'@vitest/utils@4.1.12':
resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==}
'@webcontainer/env@1.1.1':
resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==}
@@ -2467,11 +2471,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.19.0:
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
engines: {node: '>=0.4.0'}
@@ -3275,8 +3274,8 @@ packages:
flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
follow-redirects@1.16.1:
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -4732,8 +4731,8 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
storybook@10.6.0:
resolution: {integrity: sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw==}
storybook@10.6.2:
resolution: {integrity: sha512-qHDINMiapxFUxYb+xC3j8u7Y+85X3u+/PlytE/wmnyg+bU0agCW3jUmWP4vIh2MioO2jTK/2B3Lcw6IpRFzQBA==}
hasBin: true
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -5078,11 +5077,11 @@ packages:
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
vite-plugin-storybook-nextjs@10.6.0:
resolution: {integrity: sha512-BhwBZO3KEsO9IhGWOIuHSyQuedRHZ/ZfcV11kldPDH7gpOg5EP5cZ58hsSNjhtf45o806TfpPkQHFaQeYcvYlw==}
vite-plugin-storybook-nextjs@10.6.2:
resolution: {integrity: sha512-ie7ffznavYmcbP5tk5PADc2g6TicGXsIGcajLMqkFxe08v7HjZ6WslxqXLMWOS/OOC/nJapxEHDdNomWl+P3pA==}
peerDependencies:
next: ^14.1.0 || ^15.0.0 || ^16.0.0
storybook: ^10.6.0
storybook: ^10.6.2
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vite-tsconfig-paths@5.1.4:
@@ -5093,8 +5092,8 @@ packages:
vite:
optional: true
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
vite@7.3.7:
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -5133,20 +5132,20 @@ packages:
yaml:
optional: true
vitest@4.1.11:
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
vitest@4.1.12:
resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.11
'@vitest/browser-preview': 4.1.11
'@vitest/browser-webdriverio': 4.1.11
'@vitest/coverage-istanbul': 4.1.11
'@vitest/coverage-v8': 4.1.11
'@vitest/ui': 4.1.11
'@vitest/browser-playwright': 4.1.12
'@vitest/browser-preview': 4.1.12
'@vitest/browser-webdriverio': ^4.1.11
'@vitest/coverage-istanbul': 4.1.12
'@vitest/coverage-v8': 4.1.12
'@vitest/ui': 4.1.12
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -5926,11 +5925,11 @@ snapshots:
'@types/yargs': 17.0.35
chalk: 4.1.2
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(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)(yaml@2.9.1))':
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
glob: 13.0.6
react-docgen-typescript: 2.4.0(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)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
optionalDependencies:
typescript: 5.9.3
@@ -6723,14 +6722,14 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@storybook/addon-docs@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/addon-docs@10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4)
'@storybook/icons': 2.1.0(react@19.2.4)
'@storybook/react-dom-shim': 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))
'@storybook/react-dom-shim': 10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(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)
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
ts-dedent: 2.3.0
unplugin: 2.3.11
optionalDependencies:
@@ -6738,11 +6737,11 @@ snapshots:
transitivePeerDependencies:
- '@types/react-dom'
'@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)(yaml@2.9.1))':
'@storybook/builder-vite@10.6.2(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
ts-dedent: 2.3.0
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
'@storybook/global@5.0.0': {}
@@ -6750,18 +6749,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.8(@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.64.6)(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)(yaml@2.9.1))':
'@storybook/nextjs-vite@10.6.2(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@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.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
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)(yaml@2.9.1))
'@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.64.6)(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)(yaml@2.9.1))
'@storybook/builder-vite': 10.6.2(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@storybook/react': 10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
'@storybook/react-vite': 10.6.2(@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.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
next: 16.3.8(@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)
storybook: 10.6.2(@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)(yaml@2.9.1)
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.8(@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.64.6)(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)(yaml@2.9.1))
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite-plugin-storybook-nextjs: 10.6.2(next@16.3.8(@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.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -6772,30 +6771,30 @@ snapshots:
- rollup
- supports-color
'@storybook/react-dom-shim@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))':
'@storybook/react-dom-shim@10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))':
dependencies:
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)
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@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.64.6)(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)(yaml@2.9.1))':
'@storybook/react-vite@10.6.2(@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.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(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)(yaml@2.9.1))
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@rollup/pluginutils': 5.4.0(rollup@4.64.6)
'@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)(yaml@2.9.1))
'@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/builder-vite': 10.6.2(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@storybook/react': 10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
empathic: 2.1.0
magic-string: 1.4.2
react: 19.2.4
react-docgen: 8.0.4
react-dom: 19.2.4(react@19.2.4)
resolve: 1.22.12
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
tsconfig-paths: 4.2.0
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -6804,15 +6803,15 @@ snapshots:
- rollup
- supports-color
'@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@10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 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))
'@storybook/react-dom-shim': 10.6.2(@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.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
react: 19.2.4
react-docgen: 8.0.4
react-docgen-typescript: 2.4.0(typescript@5.9.3)
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)
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -7252,7 +7251,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitejs/plugin-react@5.1.4(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
'@vitejs/plugin-react@5.1.4(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7)
@@ -7260,14 +7259,14 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.11(vitest@4.1.11)':
'@vitest/coverage-v8@4.1.12(vitest@4.1.12)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.11
'@vitest/utils': 4.1.12
ast-v8-to-istanbul: 1.0.7
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
@@ -7276,7 +7275,7 @@ snapshots:
obug: 2.2.1
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1))
vitest: 4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@vitest/expect@3.2.4':
dependencies:
@@ -7286,41 +7285,41 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/expect@4.1.11':
'@vitest/expect@4.1.12':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
'@vitest/spy': 4.1.12
'@vitest/utils': 4.1.12
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/mocker@4.1.11(msw@2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1))':
'@vitest/mocker@4.1.12(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
'@vitest/spy': 4.1.11
'@vitest/spy': 4.1.12
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@4.1.11':
'@vitest/pretty-format@4.1.12':
dependencies:
tinyrainbow: 3.1.1
'@vitest/runner@4.1.11':
'@vitest/runner@4.1.12':
dependencies:
'@vitest/utils': 4.1.11
'@vitest/utils': 4.1.12
pathe: 2.0.3
'@vitest/snapshot@4.1.11':
'@vitest/snapshot@4.1.12':
dependencies:
'@vitest/pretty-format': 4.1.11
'@vitest/utils': 4.1.11
'@vitest/pretty-format': 4.1.12
'@vitest/utils': 4.1.12
magic-string: 0.30.21
pathe: 2.0.3
@@ -7328,18 +7327,18 @@ snapshots:
dependencies:
tinyspy: 4.0.6
'@vitest/spy@4.1.11': {}
'@vitest/spy@4.1.12': {}
'@vitest/ui@4.1.11(vitest@4.1.11)':
'@vitest/ui@4.1.12(vitest@4.1.12)':
dependencies:
'@vitest/utils': 4.1.11
'@vitest/utils': 4.1.12
fflate: 0.8.3
flatted: 3.4.4
pathe: 2.0.3
sirv: 3.0.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1))
vitest: 4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@vitest/utils@3.2.4':
dependencies:
@@ -7347,9 +7346,9 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
'@vitest/utils@4.1.11':
'@vitest/utils@4.1.12':
dependencies:
'@vitest/pretty-format': 4.1.11
'@vitest/pretty-format': 4.1.12
convert-source-map: 2.0.0
tinyrainbow: 3.1.1
@@ -7361,10 +7360,7 @@ snapshots:
acorn@8.16.0: {}
acorn@8.18.0: {}
acorn@8.19.0:
optional: true
acorn@8.19.0: {}
agent-base@6.0.2:
dependencies:
@@ -7526,7 +7522,7 @@ snapshots:
axios@1.20.0:
dependencies:
follow-redirects: 1.16.0
follow-redirects: 1.16.1
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
@@ -8328,7 +8324,7 @@ snapshots:
flatted@3.4.4: {}
follow-redirects@1.16.0: {}
follow-redirects@1.16.1: {}
for-each@0.3.5:
dependencies:
@@ -10193,7 +10189,7 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4):
storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4):
dependencies:
'@storybook/global': 5.0.0
'@storybook/icons': 2.1.0(react@19.2.4)
@@ -10530,7 +10526,7 @@ snapshots:
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.18.0
acorn: 8.19.0
picomatch: 4.0.7
webpack-virtual-modules: 0.6.2
@@ -10619,7 +10615,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-storybook-nextjs@10.6.0(next@16.3.8(@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.64.6)(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)(yaml@2.9.1)):
vite-plugin-storybook-nextjs@10.6.2(next@16.3.8(@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.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
'@next/env': 16.4.0
'@rollup/pluginutils': 5.4.0(rollup@4.64.6)
@@ -10629,27 +10625,27 @@ snapshots:
pathe: 2.0.3
probe-image-size: 7.4.0
semver: 7.8.5
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
ts-dedent: 2.3.0
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite-tsconfig-paths: 5.1.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)(yaml@2.9.1))
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite-tsconfig-paths@5.1.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)(yaml@2.9.1)):
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1):
vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1):
dependencies:
esbuild: 0.28.2
fdir: 6.5.0(picomatch@4.0.7)
@@ -10665,15 +10661,15 @@ snapshots:
terser: 5.51.2
yaml: 2.9.1
vitest@4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1)):
vitest@4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(msw@2.12.10(@types/node@22.19.11)(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)(yaml@2.9.1))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
'@vitest/expect': 4.1.12
'@vitest/mocker': 4.1.12(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
'@vitest/pretty-format': 4.1.12
'@vitest/runner': 4.1.12
'@vitest/snapshot': 4.1.12
'@vitest/spy': 4.1.12
'@vitest/utils': 4.1.12
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 0.30.21
@@ -10685,12 +10681,12 @@ snapshots:
tinyexec: 1.3.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.11
'@vitest/coverage-v8': 4.1.11(vitest@4.1.11)
'@vitest/ui': 4.1.11(vitest@4.1.11)
'@vitest/coverage-v8': 4.1.12(vitest@4.1.12)
'@vitest/ui': 4.1.12(vitest@4.1.12)
jsdom: 26.1.0
transitivePeerDependencies:
- msw
+1 -1
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.36</revision>
<revision>1.0.0-RC.37</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
+2 -4
View File
@@ -180,10 +180,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
// Sync user ID to QContext
useEffect(() => {
if (user?.email) {
setUserId(user.email)
}
}, [user?.email, setUserId])
setUserId(user?.id ?? user?.email)
}, [user?.id, user?.email, setUserId])
// Redirect to login if not authenticated
useEffect(() => {
@@ -167,6 +167,7 @@ export function CollaborationEdit({
return (
<div className="space-y-3">
<PresenceAvatars viewers={presence.viewers} />
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{conflict && (
<div
role="alert"
@@ -1,309 +1,318 @@
/*
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, expect, it, vi } from 'vitest'
import * as api from '@/lib/api/collaboration'
import { getRecord } from '@/lib/api/tables'
import type { RealtimeEvent } from '@/lib/realtime/client'
import { ShareDialog } from './ShareDialog'
import { RequestAccess } from './RequestAccess'
import { TasksTab } from './TasksTab'
import { CollaborationEdit } from './CollaborationEdit'
import { MyWork } from './MyWork'
import { qInstance } from '@/mocks/fixtures/q-instance'
let receive: ((event: RealtimeEvent) => void) | undefined
vi.mock('@/lib/hooks/use-realtime-channel', () => ({
useRealtimeChannel: (_channel: string, callback?: typeof receive) => {
receive = callback
},
}))
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({
useCollaborationPresence: () => ({ viewers: [] }),
}))
vi.mock('@/lib/context/q-context', async () => {
const { createContext } = await import('react')
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
})
vi.mock('@/lib/hooks/use-collaboration-core', () => ({
useCollaborationAvailable: () => ({ data: true }),
}))
vi.mock('@/lib/hooks/use-metadata', () => ({
useMetaData: () => ({ data: qInstance }),
useTableMetaData: () => ({ data: qInstance.tables.person }),
}))
vi.mock('@/lib/api/tables', () => ({
getRecord: vi.fn(),
queryRecords: vi
.fn()
.mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
}))
vi.mock('@/lib/api/collaboration', () => ({
searchPeople: vi.fn(),
mentionCandidates: vi.fn(),
getRecordLink: vi.fn(),
previewShares: vi.fn(),
shareRecord: vi.fn(),
requestAccess: vi.fn(),
listTasks: vi.fn(),
createTask: vi.fn(),
patchTask: vi.fn(),
getMyWork: vi.fn(),
listMyTasks: vi.fn(),
}))
const record = { table: 'person', pk: '1' }
function mount(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.searchPeople).mockResolvedValue({
items: [
{ userId: 'sam', displayName: 'Sam Parker', email: null },
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
{ userId: 'unknown', displayName: 'Taylor', email: null },
],
nextCursor: null,
})
vi.mocked(api.previewShares).mockImplementation(async (_record, ids) => ({
recipients: ids.map((id) => ({
userId: id,
displayName: id === 'sam' ? 'Sam Parker' : id,
canRead: id === 'unknown' ? null : id === 'alex',
})),
}))
vi.mocked(api.mentionCandidates).mockResolvedValue({
items: [
{ person: { userId: 'sam', displayName: 'Sam Parker' }, canRead: 'false' },
{ person: { userId: 'alex', displayName: 'Alex Rivera' }, canRead: 'true' },
],
nextCursor: null,
})
vi.mocked(api.getRecordLink).mockResolvedValue({ url: 'https://example.test/app/person/1' })
vi.mocked(api.shareRecord).mockResolvedValue({
items: [
{ id: 1, toUserId: 'sam', recipientCouldRead: false },
{ id: 2, toUserId: 'alex', recipientCouldRead: true },
],
})
vi.mocked(api.listTasks).mockResolvedValue({
items: [{ id: 1, title: 'Review order', status: 'OPEN' }],
nextCursor: null,
})
vi.mocked(api.listMyTasks).mockResolvedValue({ items: [], nextCursor: null })
vi.mocked(api.createTask).mockResolvedValue({ id: 2 })
vi.mocked(api.patchTask).mockResolvedValue({ id: 1 })
vi.mocked(getRecord).mockResolvedValue({
tableName: 'person',
values: { id: 1, firstName: 'Their value', modifyDate: 'v2' },
})
})
it('previews inaccessible and unknown recipients, then reports notified and offered access', async () => {
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
expect(await screen.findByText("can't open this record")).toBeVisible()
expect(screen.getByText('unknown')).toBeVisible()
fireEvent.click(screen.getByLabelText(/Sam Parker/))
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(await screen.findByText('Sam Parker: Offered access')).toBeVisible()
expect(screen.getByText('alex: Notified')).toBeVisible()
})
it('leaves the signed-in user out of share recipients even when the service returns them', async () => {
vi.mocked(api.searchPeople).mockResolvedValueOnce({
items: [
{ userId: 'ME', displayName: 'Me Myself', email: null },
{ userId: 'x9', displayName: 'Mail Match', email: 'Me' },
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
],
nextCursor: null,
})
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
expect(await screen.findByLabelText(/Alex Rivera/)).toBeVisible()
expect(screen.queryByLabelText(/Me Myself/)).toBeNull()
expect(screen.queryByLabelText(/Mail Match/)).toBeNull()
})
it('uses identical non-disclosing access copy even when the server reports missing record', async () => {
vi.mocked(api.requestAccess).mockRejectedValue(
new Error('Record does not exist: sensitive detail')
)
mount(<RequestAccess tableName="person" recordId="missing" shareId={12} />)
fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
target: { value: 'Please review' },
})
fireEvent.click(screen.getByRole('button', { name: 'Request access' }))
expect(await screen.findByRole('alert')).not.toHaveTextContent('sensitive detail')
expect(document.body).not.toHaveTextContent('does not exist')
expect(api.requestAccess).toHaveBeenCalledWith({
tableName: 'person',
recordId: 'missing',
shareId: 12,
message: 'Please review',
})
})
it('shows a stream warning, compares theirs and mine, and uses the same dialog on 409', async () => {
const props = {
table: qInstance.tables.person,
pk: '1',
available: true,
realtime: true,
error: null,
getValues: () => ({ firstName: 'My value' }),
onViewers: vi.fn(),
onOverwrite: vi.fn(),
onReload: vi.fn(),
pending: false,
}
mount(<CollaborationEdit {...props} />)
act(() =>
receive?.({
event: 'record.changed',
data: { table: 'person', pk: '1', changedBy: 'sam', fields: ['firstName'] },
})
)
expect(await screen.findByText(/Sam Parker saved changes/)).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Review changes' }))
expect(await screen.findByText('Their value')).toBeVisible()
expect(screen.getByText('My value')).toBeVisible()
expect(screen.queryByRole('button', { name: 'Keep mine (overwrite)' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Use theirs' }))
expect(props.onReload).toHaveBeenCalled()
})
it('opens 409 review automatically and overwrites only with the refreshed version', async () => {
const overwrite = vi.fn()
const error = Object.assign(new Error('Conflict'), {
status: 409,
response: {
status: 409,
data: { currentVersion: 'v2', changedFields: ['firstName'], modifiedByUserId: 'sam' },
},
})
mount(
<CollaborationEdit
table={qInstance.tables.person}
pk="1"
available
realtime
error={error}
getValues={() => ({ firstName: 'Mine' })}
onViewers={vi.fn()}
onOverwrite={overwrite}
onReload={vi.fn()}
pending={false}
/>
)
expect(await screen.findByText('Their value')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Keep mine (overwrite)' }))
expect(overwrite).toHaveBeenCalledWith('v2')
})
it('refuses unreadable assignees, creates and toggles a task', async () => {
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
fireEvent.click(await screen.findByRole('checkbox', { name: 'Complete Review order' }))
await waitFor(() => expect(api.patchTask).toHaveBeenCalledWith(1, { status: 'DONE' }))
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
await waitFor(() => expect(screen.getByLabelText(/Sam Parker/)).toBeDisabled())
await waitFor(() => expect(screen.getByLabelText(/Alex Rivera/)).toBeEnabled())
expect(api.previewShares).not.toHaveBeenCalled()
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), {
target: { value: 'New task' },
})
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
fireEvent.click(screen.getByRole('button', { name: 'Save task' }))
await waitFor(() =>
expect(api.createTask).toHaveBeenCalledWith(
record,
expect.objectContaining({ title: 'New task', assigneeUserId: 'alex' })
)
)
})
it('names task people by display name and falls back to the user id', async () => {
vi.mocked(api.listTasks).mockResolvedValue({
items: [
{
id: 1,
title: 'Named',
status: 'DONE',
assigneeUserId: 'alex',
assigneeDisplayName: 'Alex Rivera',
createdByUserId: 'morgan',
createdByDisplayName: 'Morgan Lee',
completedByUserId: 'sam',
completedByDisplayName: 'Sam Parker',
},
{ id: 2, title: 'Unnamed', status: 'OPEN', assigneeUserId: 'u7', createdByUserId: 'u8' },
],
nextCursor: null,
})
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
expect(await screen.findByText('Assigned to u7')).toBeVisible()
fireEvent.click(screen.getByRole('checkbox', { name: 'Show done' }))
expect(await screen.findByText('Assigned to Alex Rivera')).toBeVisible()
expect(screen.getByText('Created by Morgan Lee · Completed by Sam Parker')).toBeVisible()
expect(screen.getByText('Assigned to u7')).toBeVisible()
expect(screen.getByText('Created by u8')).toBeVisible()
})
it('renders My work with overdue task, assigned table and unread mentions links', async () => {
vi.mocked(api.getMyWork).mockResolvedValue({
openTaskCount: 1,
tasks: [
{
id: 1,
title: 'Review order',
status: 'OPEN',
tableName: 'person',
recordId: '1',
dueAt: '2020-01-01T00:00:00Z',
},
],
assignedRecordsByTable: { person: 1 },
unreadMentionCount: 3,
})
vi.mocked(api.listMyTasks).mockResolvedValue({
items: [
{
id: 1,
title: 'Review order',
status: 'OPEN',
tableName: 'person',
recordId: '1',
dueAt: '2020-01-01T00:00:00Z',
},
],
nextCursor: null,
})
mount(<MyWork />)
expect(await screen.findByRole('link', { name: 'Review order' })).toHaveAttribute(
'href',
'/app/person/1'
)
expect(await screen.findByRole('link', { name: 'Alice' })).toHaveAttribute(
'href',
'/app/person/1'
)
expect(screen.getByText(/Overdue:/)).toBeVisible()
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
'href',
expect.stringContaining('CurrentUser')
)
})
/*
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, expect, it, vi } from 'vitest'
import * as api from '@/lib/api/collaboration'
import { getRecord } from '@/lib/api/tables'
import type { RealtimeEvent } from '@/lib/realtime/client'
import { ShareDialog } from './ShareDialog'
import { RequestAccess } from './RequestAccess'
import { TasksTab } from './TasksTab'
import { CollaborationEdit } from './CollaborationEdit'
import { MyWork } from './MyWork'
import { qInstance } from '@/mocks/fixtures/q-instance'
let receive: ((event: RealtimeEvent) => void) | undefined
vi.mock('@/lib/hooks/use-realtime-channel', () => ({
useRealtimeChannel: (_channel: string, callback?: typeof receive) => {
receive = callback
},
}))
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({
useCollaborationPresence: () => ({ viewers: [] }),
}))
vi.mock('@/lib/context/q-context', async () => {
const { createContext } = await import('react')
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
})
vi.mock('@/lib/hooks/use-collaboration-core', () => ({
useCollaborationAvailable: () => ({ data: true }),
}))
vi.mock('@/lib/hooks/use-metadata', () => ({
useMetaData: () => ({ data: qInstance }),
useTableMetaData: () => ({ data: qInstance.tables.person }),
}))
vi.mock('@/lib/api/tables', () => ({
getRecord: vi.fn(),
queryRecords: vi.fn().mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
}))
vi.mock('@/lib/api/collaboration', () => ({
searchPeople: vi.fn(),
mentionCandidates: vi.fn(),
getRecordLink: vi.fn(),
previewShares: vi.fn(),
shareRecord: vi.fn(),
requestAccess: vi.fn(),
listTasks: vi.fn(),
createTask: vi.fn(),
patchTask: vi.fn(),
getMyWork: vi.fn(),
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
listMyTasks: vi.fn(),
}))
const record = { table: 'person', pk: '1' }
function mount(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.searchPeople).mockResolvedValue({
items: [
{ userId: 'sam', displayName: 'Sam Parker', email: null },
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
{ userId: 'unknown', displayName: 'Taylor', email: null },
],
nextCursor: null,
})
vi.mocked(api.previewShares).mockImplementation(async (_record, ids) => ({
recipients: ids.map((id) => ({
userId: id,
displayName: id === 'sam' ? 'Sam Parker' : id,
canRead: id === 'unknown' ? null : id === 'alex',
})),
}))
vi.mocked(api.mentionCandidates).mockResolvedValue({
items: [
{ person: { userId: 'sam', displayName: 'Sam Parker' }, canRead: 'false' },
{ person: { userId: 'alex', displayName: 'Alex Rivera' }, canRead: 'true' },
],
nextCursor: null,
})
vi.mocked(api.getRecordLink).mockResolvedValue({ url: 'https://example.test/app/person/1' })
vi.mocked(api.shareRecord).mockResolvedValue({
items: [
{ id: 1, toUserId: 'sam', recipientCouldRead: false },
{ id: 2, toUserId: 'alex', recipientCouldRead: true },
],
})
vi.mocked(api.listTasks).mockResolvedValue({
items: [{ id: 1, title: 'Review order', status: 'OPEN' }],
nextCursor: null,
})
vi.mocked(api.listMyTasks).mockResolvedValue({ items: [], nextCursor: null })
vi.mocked(api.createTask).mockResolvedValue({ id: 2 })
vi.mocked(api.patchTask).mockResolvedValue({ id: 1 })
vi.mocked(getRecord).mockResolvedValue({
tableName: 'person',
values: { id: 1, firstName: 'Their value', modifyDate: 'v2' },
})
})
it('previews inaccessible and unknown recipients, then reports notified and offered access', async () => {
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
expect(await screen.findByText("can't open this record")).toBeVisible()
expect(screen.getByText('unknown')).toBeVisible()
fireEvent.click(screen.getByLabelText(/Sam Parker/))
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(await screen.findByText('Sam Parker: Offered access')).toBeVisible()
expect(screen.getByText('alex: Notified')).toBeVisible()
})
it('leaves the signed-in user out of share recipients even when the service returns them', async () => {
vi.mocked(api.searchPeople).mockResolvedValueOnce({
items: [
{ userId: 'ME', displayName: 'Me Myself', email: null },
{ userId: 'x9', displayName: 'Mail Match', email: 'Me' },
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
],
nextCursor: null,
})
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
expect(await screen.findByLabelText(/Alex Rivera/)).toBeVisible()
expect(screen.queryByLabelText(/Me Myself/)).toBeNull()
expect(screen.queryByLabelText(/Mail Match/)).toBeNull()
})
it('uses identical non-disclosing access copy even when the server reports missing record', async () => {
vi.mocked(api.requestAccess).mockRejectedValue(
new Error('Record does not exist: sensitive detail')
)
mount(<RequestAccess tableName="person" recordId="missing" shareId={12} />)
fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
target: { value: 'Please review' },
})
fireEvent.click(screen.getByRole('button', { name: 'Request access' }))
expect(await screen.findByRole('alert')).not.toHaveTextContent('sensitive detail')
expect(document.body).not.toHaveTextContent('does not exist')
expect(api.requestAccess).toHaveBeenCalledWith({
tableName: 'person',
recordId: 'missing',
shareId: 12,
message: 'Please review',
})
})
it('shows a stream warning, compares theirs and mine, and uses the same dialog on 409', async () => {
const props = {
table: qInstance.tables.person,
pk: '1',
available: true,
realtime: true,
error: null,
getValues: () => ({ firstName: 'My value' }),
onViewers: vi.fn(),
onOverwrite: vi.fn(),
onReload: vi.fn(),
pending: false,
}
mount(<CollaborationEdit {...props} />)
act(() =>
receive?.({
event: 'record.changed',
data: { table: 'person', pk: '1', changedBy: 'sam', fields: ['firstName'] },
})
)
expect(await screen.findByText(/Sam Parker saved changes/)).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Review changes' }))
expect(await screen.findByText('Their value')).toBeVisible()
expect(screen.getByText('My value')).toBeVisible()
expect(screen.queryByRole('button', { name: 'Keep mine (overwrite)' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Use theirs' }))
expect(props.onReload).toHaveBeenCalled()
})
it('opens 409 review automatically and overwrites only with the refreshed version', async () => {
const overwrite = vi.fn()
const error = Object.assign(new Error('Conflict'), {
status: 409,
response: {
status: 409,
data: { currentVersion: 'v2', changedFields: ['firstName'], modifiedByUserId: 'sam' },
},
})
mount(
<CollaborationEdit
table={qInstance.tables.person}
pk="1"
available
realtime
error={error}
getValues={() => ({ firstName: 'Mine' })}
onViewers={vi.fn()}
onOverwrite={overwrite}
onReload={vi.fn()}
pending={false}
/>
)
expect(await screen.findByText('Their value')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Keep mine (overwrite)' }))
expect(overwrite).toHaveBeenCalledWith('v2')
})
it('refuses unreadable assignees, creates and toggles a task', async () => {
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
fireEvent.click(await screen.findByRole('checkbox', { name: 'Complete Review order' }))
await waitFor(() => expect(api.patchTask).toHaveBeenCalledWith(1, { status: 'DONE' }))
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
await waitFor(() => expect(screen.getByLabelText(/Sam Parker/)).toBeDisabled())
await waitFor(() => expect(screen.getByLabelText(/Alex Rivera/)).toBeEnabled())
expect(api.previewShares).not.toHaveBeenCalled()
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), {
target: { value: 'New task' },
})
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
fireEvent.click(screen.getByRole('button', { name: 'Save task' }))
await waitFor(() =>
expect(api.createTask).toHaveBeenCalledWith(
record,
expect.objectContaining({ title: 'New task', assigneeUserId: 'alex' })
)
)
})
it('names task people by display name and falls back to the user id', async () => {
vi.mocked(api.listTasks).mockResolvedValue({
items: [
{
id: 1,
title: 'Named',
status: 'DONE',
assigneeUserId: 'alex',
assigneeDisplayName: 'Alex Rivera',
createdByUserId: 'morgan',
createdByDisplayName: 'Morgan Lee',
completedByUserId: 'sam',
completedByDisplayName: 'Sam Parker',
},
{ id: 2, title: 'Unnamed', status: 'OPEN', assigneeUserId: 'u7', createdByUserId: 'u8' },
],
nextCursor: null,
})
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
expect(await screen.findByText('Assigned to u7')).toBeVisible()
fireEvent.click(screen.getByRole('checkbox', { name: 'Show done' }))
expect(await screen.findByText('Assigned to Alex Rivera')).toBeVisible()
expect(screen.getByText('Created by Morgan Lee · Completed by Sam Parker')).toBeVisible()
expect(screen.getByText('Assigned to u7')).toBeVisible()
expect(screen.getByText('Created by u8')).toBeVisible()
})
it('renders My work with overdue task, assigned table and unread mentions links', async () => {
vi.mocked(api.getMyWork).mockResolvedValue({
openTaskCount: 1,
tasks: [
{
id: 1,
title: 'Review order',
status: 'OPEN',
tableName: 'person',
recordId: '1',
dueAt: '2020-01-01T00:00:00Z',
},
],
assignedRecordsByTable: { person: 1 },
unreadMentionCount: 3,
})
vi.mocked(api.listMyTasks).mockResolvedValue({
items: [
{
id: 1,
title: 'Review order',
status: 'OPEN',
tableName: 'person',
recordId: '1',
dueAt: '2020-01-01T00:00:00Z',
},
],
nextCursor: null,
})
mount(<MyWork />)
expect(await screen.findByRole('link', { name: 'Review order' })).toHaveAttribute(
'href',
'/app/person/1'
)
expect(await screen.findByRole('link', { name: 'Alice' })).toHaveAttribute(
'href',
'/app/person/1'
)
expect(screen.getByText(/Overdue:/)).toBeVisible()
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
vi.mocked(api.getMyWork).mockResolvedValue({
openTaskCount: 1,
tasks: [],
assignedRecordsByTable: { person: 1 },
unreadMentionCount: 0,
})
fireEvent.click(screen.getByRole('button', { name: 'Mark all mentions read' }))
await waitFor(() => expect(api.markMentionsRead).toHaveBeenCalledTimes(1))
expect(await screen.findByRole('link', { name: '0 unread mentions' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Mark all mentions read' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
'href',
expect.stringContaining('CurrentUser')
)
})
+18 -2
View File
@@ -17,11 +17,11 @@
/** @file Personal tasks, assigned record links and unread mentions. */
'use client'
import Link from 'next/link'
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getMyWork, listMyTasks } from '@/lib/api/collaboration'
import { getMyWork, listMyTasks, markMentionsRead } from '@/lib/api/collaboration'
import { queryRecords } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { collaborationButton } from './CollaborationDialog'
@@ -78,6 +78,11 @@ function AssignedTable({ name, label, count }: { name: string; label: string; co
* @returns Work page.
*/
export function MyWork() {
const cache = useQueryClient()
const read = useMutation({
mutationFn: markMentionsRead,
onSuccess: () => cache.invalidateQueries({ queryKey: queryKeys.collaborationMyWork() }),
})
const available = useCollaborationAvailable()
const metadata = useMetaData()
const work = useQuery({
@@ -158,6 +163,17 @@ export function MyWork() {
</section>
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="text-lg font-semibold">Unread mentions</h2>
{!!work.data.unreadMentionCount && (
<button
type="button"
className={collaborationButton}
disabled={read.isPending}
onClick={() => read.mutate()}
>
Mark all mentions read
</button>
)}
{read.isError && <p role="alert">Could not mark mentions read.</p>}
<Link
href="/app/notifications?status=unread"
className="text-sm underline"
@@ -24,6 +24,8 @@ import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getRecordFeatures, putAssignment } from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { updateRecord } from '@/lib/api/tables'
import { recordVersion } from '@/lib/utils/record-version'
import { pluralize } from '@/lib/utils/string-utils'
import { PresenceAvatars } from './PresenceAvatars'
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
@@ -66,7 +68,23 @@ export function RecordCollaborationHeader({
enabled: available && !!features,
})
const assignment = useMutation({
mutationFn: () => putAssignment(target, { [role]: selected[0] ?? null }),
mutationFn: async () => {
const nativeField =
role === 'ownerUserId'
? features?.ownership?.ownerFieldName
: features?.ownership?.assigneeFieldName
if (nativeField) {
await updateRecord(
table.name,
pk,
{ [nativeField]: selected[0] ?? null },
undefined,
recordVersion(table, record)
)
} else {
await putAssignment(target, { [role]: selected[0] ?? null })
}
},
onSuccess: () => {
setAssign(false)
void cache.invalidateQueries({ queryKey: queryKeys.collaborationRecord(table.name, pk) })
@@ -89,6 +107,7 @@ export function RecordCollaborationHeader({
data-qqq-id="record-collaboration-header"
>
{presence.viewers.length > 0 && <PresenceAvatars viewers={presence.viewers} />}
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{(owner != null || assignee != null) && (
<dl className="flex flex-wrap items-center gap-x-3 gap-y-1">
{owner != null && (
@@ -151,11 +170,11 @@ export function RecordCollaborationHeader({
<PeoplePicker record={target} selected={selected} onChange={setSelected} requireRead />
<button
className={collaborationButton}
disabled={assignment.isPending || !selected.length}
disabled={assignment.isPending}
onClick={() => assignment.mutate()}
data-qqq-id="assignment-save"
>
Assign
{selected.length ? 'Assign' : 'Clear assignment'}
</button>
{assignment.isError && <p role="alert">{assignment.error.message}</p>}
</CollaborationDialog>
@@ -176,7 +195,11 @@ export function TablePresence({ table, available }: { table: QTableMetaData; ava
return (
<div className="flex flex-wrap items-center gap-2 text-sm" data-qqq-id="table-presence">
<PresenceAvatars viewers={presence.viewers} />
<span>{pluralize(presence.viewers.length, 'person', 'people')} viewing</span>
<span role={presence.unavailable ? 'status' : undefined}>
{presence.unavailable
? 'Presence temporarily unavailable'
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
</span>
</div>
)
}
@@ -18,7 +18,7 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
vi.mock('@/lib/api/tables', () => ({ getRecord: vi.fn(() => new Promise(() => {})), queryRecords: vi.fn(() => new Promise(() => {})) }))
@@ -87,6 +87,8 @@ describe('WidgetRenderer', () => {
<WidgetRenderer widgetMetaData={{ name: 'untyped', label: 'Untyped', hasPermission: true }} data={{ type: 'chart', title: 'Persons', chartData: { labels: ['Jan'], datasets: [{ label: 'People', data: [17] }] } }} />
</QueryClientProvider>
)
// Await the real lazy chunk before checking content under a busy full-suite worker pool.
await act(async () => { await vi.dynamicImportSettled() })
expect(await screen.findByText('Persons', { selector: '[data-qqq-id="chart-title-untyped"]' })).toBeInTheDocument()
})
+10 -8
View File
@@ -46,14 +46,16 @@ it('searches the directory with a bounded page and normalizes each person', asyn
expect(result.items[1].userId).toBeNull()
})
it('sends the last presence lease as a JSON beacon, and tolerates browsers without one', () => {
const sendBeacon = vi.fn()
Object.defineProperty(navigator, 'sendBeacon', { value: sendBeacon, configurable: true })
beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)
expect(sendBeacon).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.any(Blob))
expect((sendBeacon.mock.calls[0][1] as Blob).type).toBe('application/json')
Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true })
expect(() => beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)).not.toThrow()
it('sends an ordered DELETE that survives page teardown and tolerates network failure', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response())
beaconPresence({ connectionId: 'tab', table: 'person', pk: '1', mode: 'VIEWING', sequence: 4 })
expect(fetch).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.objectContaining({
method: 'DELETE', credentials: 'include', keepalive: true,
body: JSON.stringify({ connectionId: 'tab', table: 'person', pk: '1', sequence: 4 }),
}))
fetch.mockRejectedValue(new Error('offline'))
expect(() => beaconPresence({ connectionId: 'tab', table: 'person', mode: 'VIEWING', sequence: 5 })).not.toThrow()
await Promise.resolve()
})
it('reads open personal tasks and rejects a task without an identifier', async () => {
+26 -7
View File
@@ -649,28 +649,47 @@ export async function listTimeline(record: CollaborationRecord, cursor?: string,
* @returns Current merged viewers.
*/
export const renewPresence = (input: PresenceRequest) =>
apiClient.post<PresenceSnapshot>(`${BASE}/presence`, input)
apiClient.post<PresenceSnapshot>(`${BASE}/presence/leases/renew`, input)
/** Reserves an invisible lease for activation by a visible surface.
* @param target - Authorized table or record.
* @returns Server-issued connection handle.
*/
export const openPresenceLease = (target: { table: string; pk?: string }) =>
apiClient.post<{ connectionId: string }>(`${BASE}/presence/leases`, target)
/** Leaves a target.
* @param input - Lease identity.
* @returns Acknowledgement.
*/
export const leavePresence = (input: PresenceTarget) =>
apiClient.delete(`${BASE}/presence`, { data: input })
/** Acknowledges only the caller's currently visible unread mentions.
* @returns Mutation acknowledgement.
*/
export const markMentionsRead = () => apiClient.post(`${BASE}/my-work/mentions/read`, {})
/** Reads current viewers.
* @param target - Target identity.
* @returns Snapshot.
*/
export const getPresence = (target: { table: string; pk?: string }) =>
apiClient.get<PresenceSnapshot>(`${BASE}/presence`, { params: target })
/** Sends the last heartbeat on pagehide. Beacon only supports POST; DELETE remains the
* unmount cleanup, and the server's 45-second TTL handles a closed document.
/** Sends a best-effort departure that survives page teardown; expiry covers network loss.
* @param input - Last presence lease.
*/
export function beaconPresence(input: PresenceRequest) {
navigator.sendBeacon?.(
`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`,
new Blob([JSON.stringify(input)], { type: 'application/json' })
)
void fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`, {
method: 'DELETE',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui' },
body: JSON.stringify({
connectionId: input.connectionId,
table: input.table,
pk: input.pk,
sequence: input.sequence,
}),
}).catch(() => undefined)
}
/** Reads directory candidates.
* @param q - Search text.
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
/*
* 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 React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
vi.mock('@/lib/api/ui-api-version', () => ({ checkUiApiCompatibility: vi.fn(async () => ({ compatible: true, info: { apiVersion: '1.1.0', features: [], extensions: [], preSemver: false } })) }))
vi.mock('@/lib/api/auth', () => ({
getAuthenticationMetaData: vi.fn(async () => ({ name: 'auth0', type: 'AUTH_0' })),
manageSession: vi.fn(), resumeSession: vi.fn(), logout: vi.fn(), clearAuthMetadataCache: vi.fn(),
}))
import { manageSession, resumeSession } from '@/lib/api/auth'
import { AuthProvider } from './auth-provider'
import { useAuth } from './use-auth'
import { getStoredUser, setSessionHint, storeUser } from './auth-storage'
import * as oidc from './oidc'
let auth: ReturnType<typeof useAuth> | null = null
/** Exposes the real provider state for callback and reload assertions. */
function Probe() {
auth = useAuth()
if (auth.isLoading) return <p>loading</p>
return <p>{auth.isAuthenticated ? `${auth.user?.name} <${auth.user?.email}>` : 'signed out'}</p>
}
describe('Auth0 identity with a server-issued user ID', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.mocked(manageSession).mockReset()
vi.mocked(resumeSession).mockReset()
auth = null
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
sessionStorage.clear()
})
it('keeps the provider display identity and server ID after sign-in', async () => {
const payload = btoa(JSON.stringify({ name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' }))
vi.spyOn(oidc, 'exchangeAuth0Code').mockResolvedValue({ access_token: 'test-access', id_token: `h.${payload}.s` })
vi.mocked(manageSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('signed out')
sessionStorage.setItem(oidc.PKCE_STORAGE.state, 'expected-state')
await act(async () => { await auth?.handleOAuthCallback('code', 'expected-state', 'verifier') })
expect(screen.getByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
expect(getStoredUser()).toEqual({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
})
it('retains the display identity on reload when the server supplies the same ID', async () => {
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
setSessionHint(true)
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
render(<AuthProvider><Probe /></AuthProvider>)
expect(await screen.findByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
})
it('prefers server display fields and does not reuse a different user profile', async () => {
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
setSessionHint(true)
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli', name: 'Eli' } } })
render(<AuthProvider><Probe /></AuthProvider>)
expect(await screen.findByText('Eli <undefined>')).toBeInTheDocument()
expect(auth?.user).toEqual({ id: 'oidc|eli', name: 'Eli', email: undefined })
})
it('does not reuse a cached profile when an ID-only session changes user', async () => {
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
setSessionHint(true)
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli' } } })
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('undefined <undefined>')
expect(auth?.user?.id).toBe('oidc|eli')
})
})
+8 -2
View File
@@ -185,11 +185,17 @@ function signInErrorMessage(error: unknown): string {
* The identity to display for a session.
*
* @param response - The manageSession response.
* @param fallback - Identity to use when the session carries none.
* @param fallback - Display identity when the session carries no display fields.
* @returns The user.
*/
function sessionUser(response: SessionResponse, fallback: AuthUser | null): AuthUser {
return userFromSessionValues(response.values) ?? fallback ?? {}
const session = userFromSessionValues(response.values)
if (!session) return fallback ?? {}
if (session.name || session.email) return session
// An ID-only backend identity must retain its authoritative ID without dropping
// the provider's display profile. Never reuse a profile for a different user ID.
if (session.id && fallback?.id && session.id !== fallback.id) return session
return { ...session, name: fallback?.name, email: fallback?.email }
}
/**
+45 -12
View File
@@ -43,6 +43,16 @@ afterEach(() => {
})
describe('auth-storage', () => {
it('preserves the canonical identity independently of the display email', () => {
const user = userFromSessionValues({
user: { id: 'alice', name: 'Alice', email: 'alice@example.test' },
})
expect(user).toEqual({ id: 'alice', name: 'Alice', email: 'alice@example.test' })
storeUser(user!)
expect(getStoredUser()?.id).toBe('alice')
expect(userFromSessionValues({ user: { id: 'service-user' } })?.id).toBe('service-user')
})
it.each([null, 'true', 'false'])('preserves appearance preference %s on logout', (preference) => {
if (preference !== null) localStorage.setItem('qqq-dark-mode', preference)
clearUserClientData()
@@ -50,14 +60,22 @@ describe('auth-storage', () => {
})
it('reads the displayed user from manageSession values (QRun-IO/qqq#332)', () => {
expect(userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
expect(
userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })
).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
expect(userFromSessionValues({ user: { name: '', email: 7 } })).toBeNull()
expect(userFromSessionValues(undefined)).toBeNull()
})
it('shows a TABLE_BASED username as the identity line (QRun-IO/qqq#700)', () => {
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess.table' })
expect(userFromSessionValues({ user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual(
{ name: 'Tess Table', email: 'tess.table' }
)
expect(
userFromSessionValues({
user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' },
})
).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
})
it('persists and validates the stored user', () => {
@@ -70,7 +88,10 @@ describe('auth-storage', () => {
})
it('keeps the full session values for analytics identity until sign-out (QRun-IO/qqq#730)', () => {
const values = { user: { name: 'Alice (sample)', email: 'sample:alice' }, analyticsValues: { user_id: 'u-1' } }
const values = {
user: { name: 'Alice (sample)', email: 'sample:alice' },
analyticsValues: { user_id: 'u-1' },
}
storeSessionValues(values)
expect(getStoredSessionValues()).toEqual(values)
storeSessionValues(undefined)
@@ -109,15 +130,19 @@ describe('auth-storage', () => {
expect(localStorage.getItem('accessToken')).toBeNull()
})
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
it("clears every table's remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)", () => {
for (const table of ['person', 'pet']) {
writeStoredQueryView(table, {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'empty',
})
writeCurrentSavedViewId(table, 7)
expect(readStoredQueryView(table)).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
})
expect(readCurrentSavedViewId(table)).toBe(7)
}
@@ -136,20 +161,25 @@ describe('auth-storage', () => {
expect(readStoredQueryView(table)).toBeNull()
expect(readCurrentSavedViewId(table)).toBeNull()
}
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
for (const [key, value] of Object.entries(preferences))
expect(localStorage.getItem(key)).toBe(value)
})
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
claimClientData({ name: 'Alice', email: 'alice@example.test' })
writeStoredQueryView('person', {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'savedView:7',
})
writeCurrentSavedViewId('person', 7)
claimClientData({ name: 'Alice', email: 'alice@example.test' })
expect(readStoredQueryView('person')).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'savedView:7',
})
expect(readCurrentSavedViewId('person')).toBe(7)
@@ -160,9 +190,12 @@ describe('auth-storage', () => {
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
})
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
it("clears the previous user's data when someone else signs in in this browser (QRun-IO/qqq#696)", () => {
claimClientData({ name: 'Alice', email: 'alice@example.com' })
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
localStorage.setItem(
'qqq-recent-records',
JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }])
)
storeUser({ name: 'Alice', email: 'alice@example.com' })
claimClientData({ name: 'Alice', email: 'alice@example.com' })
+19 -6
View File
@@ -31,6 +31,7 @@ const SESSION_HINT_KEY = 'qqq.sessionHint'
/** The displayed identity of the signed-in user. */
export interface StoredUser {
id?: string
name?: string
email?: string
}
@@ -102,16 +103,24 @@ export function setSessionHint(present: boolean): void {
* @param values - The session values for the frontend.
* @returns The user when it has a name or email.
*/
export function userFromSessionValues(values: Record<string, unknown> | undefined): StoredUser | null {
export function userFromSessionValues(
values: Record<string, unknown> | undefined
): StoredUser | null {
const user = values?.user
if (!user || typeof user !== 'object') return null
const { name, email, username } = user as Record<string, unknown>
const identity = typeof email === 'string' && email ? email : typeof username === 'string' && username ? username : undefined
const { id, name, email, username } = user as Record<string, unknown>
const identity =
typeof email === 'string' && email
? email
: typeof username === 'string' && username
? username
: undefined
const result: StoredUser = {
...(typeof id === 'string' && id ? { id } : {}),
name: typeof name === 'string' && name ? name : undefined,
email: identity,
}
return result.name || result.email ? result : null
return result.id || result.name || result.email ? result : null
}
/**
@@ -162,7 +171,9 @@ export function storeSessionValues(values: Record<string, unknown> | undefined):
export function getStoredSessionValues(): Record<string, unknown> | null {
try {
const parsed = JSON.parse(storage('local')?.getItem(SESSION_VALUES_KEY) ?? 'null') as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
@@ -179,7 +190,9 @@ export function recordReauthAttempt(now = Date.now()): boolean {
const session = storage('session')
let attempts: number[] = []
try {
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter((time) => now - time < 60_000)
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter(
(time) => now - time < 60_000
)
} catch {
attempts = []
}
@@ -26,6 +26,7 @@ vi.mock('@/lib/context/q-context', async () => {
})
vi.mock('./use-realtime-channel', () => ({ useRealtimeChannel: vi.fn() }))
vi.mock('@/lib/api/collaboration', () => ({
openPresenceLease: vi.fn(),
renewPresence: vi.fn(),
leavePresence: vi.fn(),
getPresence: vi.fn(),
@@ -33,6 +34,10 @@ vi.mock('@/lib/api/collaboration', () => ({
}))
beforeEach(() => {
vi.useFakeTimers()
let nextLease = 0
vi.mocked(api.openPresenceLease).mockImplementation(async () => ({
connectionId: `lease:${++nextLease}`,
}))
vi.mocked(api.getPresence).mockResolvedValue({ table: 'person', viewers: [] })
vi.mocked(api.renewPresence).mockResolvedValue({ table: 'person', viewers: [] })
vi.mocked(api.leavePresence).mockResolvedValue({})
@@ -50,12 +55,15 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
wrapper,
initialProps: { field: 'firstName' },
})
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.renewPresence).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'EDITING', fieldName: 'firstName' })
)
await act(() => vi.advanceTimersByTimeAsync(20000))
expect(api.renewPresence).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.renewPresence).mock.calls.map(([input]) => input.sequence)).toEqual([1, 2])
hook.rerender({ field: 'lastName' })
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ fieldName: 'lastName' })
)
@@ -64,9 +72,9 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
expect.objectContaining({ fieldName: 'lastName' })
)
hook.unmount()
expect(api.leavePresence).toHaveBeenCalledTimes(1)
expect(api.leavePresence).toHaveBeenCalledWith(
expect.objectContaining({ table: 'person', pk: '1' })
expect(api.leavePresence).not.toHaveBeenCalled()
expect(api.beaconPresence).toHaveBeenCalledWith(
expect.objectContaining({ table: 'person', pk: '1', sequence: 4 })
)
})
it('merges tabs and excludes my own tabs, preferring editors', () => {
@@ -81,3 +89,155 @@ it('merges tabs and excludes my own tabs, preferring editors', () => {
)
).toEqual([{ userId: 'sam', displayName: 'Sam', mode: 'EDITING', fieldName: 'name' }])
})
it('reports failed renewals even when reads succeed, and recovers on a successful renewal', async () => {
vi.mocked(api.renewPresence).mockRejectedValueOnce(new Error('Unavailable'))
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(10))
expect(hook.result.current.unavailable).toBe(true)
expect(hook.result.current.viewers).toEqual([])
await act(() => vi.advanceTimersByTimeAsync(20000))
expect(hook.result.current.unavailable).toBe(false)
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
hook.unmount()
})
it('ignores a failed renewal from the previous record', async () => {
let rejectOld: (reason: Error) => void = () => undefined
vi.mocked(api.renewPresence).mockReturnValueOnce(
new Promise((_, reject) => {
rejectOld = reject
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(({ pk }) => useCollaborationPresence('person', pk), {
wrapper,
initialProps: { pk: '1' },
})
await act(() => vi.advanceTimersByTimeAsync(0))
hook.rerender({ pk: '2' })
await act(() => vi.advanceTimersByTimeAsync(10))
await act(async () => {
rejectOld(new Error('Old request failed'))
})
expect(hook.result.current.unavailable).toBe(false)
hook.unmount()
})
it('releases a late reservation without activating a closed surface', async () => {
let resolveOpen!: (value: { connectionId: string }) => void
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
new Promise((resolve) => {
resolveOpen = resolve
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
hook.unmount()
await act(async () => {
resolveOpen({ connectionId: 'lease:late' })
})
expect(api.renewPresence).not.toHaveBeenCalled()
expect(api.leavePresence).toHaveBeenCalledWith({
table: 'person',
pk: '1',
connectionId: 'lease:late',
sequence: 1,
})
})
it('rejoins once with a new handle after an explicit lease expiry', async () => {
vi.mocked(api.renewPresence).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
})
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(
vi.mocked(api.renewPresence).mock.calls.map(([input]) => [input.connectionId, input.sequence])
).toEqual([
['lease:1', 1],
['lease:2', 1],
])
expect(hook.result.current.unavailable).toBe(false)
hook.unmount()
})
it('opens a fresh lease on pageshow instead of reviving the departed handle', async () => {
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
act(() => window.dispatchEvent(new Event('pagehide')))
act(() => window.dispatchEvent(new Event('pageshow')))
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:2', sequence: 1 })
)
hook.unmount()
})
it('bounds immediate rejoin attempts when replacement leases also expire', async () => {
vi.mocked(api.renewPresence).mockRejectedValue({
isAxiosError: true,
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
})
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(hook.result.current.unavailable).toBe(true)
hook.unmount()
})
it('discards an old pending open after hide and show while preserving the new handle', async () => {
let resolveOld!: (value: { connectionId: string }) => void
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
act(() => window.dispatchEvent(new Event('pagehide')))
act(() => window.dispatchEvent(new Event('pageshow')))
await act(() => vi.advanceTimersByTimeAsync(0))
await act(async () => {
resolveOld({ connectionId: 'lease:old' })
})
expect(api.renewPresence).toHaveBeenCalledTimes(1)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:1', sequence: 1 })
)
expect(api.leavePresence).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'lease:old', sequence: 1 })
)
hook.unmount()
expect(api.leavePresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:1', sequence: 2 })
)
})
+121 -26
View File
@@ -16,10 +16,17 @@
/** @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh. */
'use client'
import { useContext, useEffect, useState } from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import type { PresenceSnapshot, PresenceViewer } from '@/types/collaboration'
import { renewPresence, leavePresence, getPresence, beaconPresence } from '@/lib/api/collaboration'
import {
openPresenceLease,
renewPresence,
leavePresence,
getPresence,
beaconPresence,
} from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { QContext } from '@/lib/context/q-context'
import { useRealtimeChannel } from './use-realtime-channel'
@@ -51,8 +58,11 @@ export function useCollaborationPresence(
fieldName?: string,
enabled = true
) {
const [connectionId] = useState(() => crypto.randomUUID())
const focus = useRef({ editing, fieldName })
focus.current = { editing, fieldName }
const refresh = useRef<() => void>(() => undefined)
const [ended, setEnded] = useState(false)
const [renewalFailed, setRenewalFailed] = useState(false)
const userId = useContext(QContext)?.userId
const cache = useQueryClient()
const key = queryKeys.collaborationResource(table, pk ?? '', 'presence')
@@ -65,7 +75,14 @@ export function useCollaborationPresence(
useRealtimeChannel(
pk ? `record:${table}:${pk}` : `table:${table}`,
({ event, data }) => {
if (event === 'session-ended') setEnded(true)
if (
event === 'session-ended' ||
(event === 'channel-revoked' &&
data.channel === (pk ? `record:${table}:${pk}` : `table:${table}`))
) {
setEnded(true)
cache.setQueryData(key, { table, pk, viewers: [] })
}
if (
event === 'presence.changed' &&
data.table === table &&
@@ -78,34 +95,112 @@ export function useCollaborationPresence(
)
useEffect(() => {
if (!enabled || ended) return
const input = {
connectionId,
table,
pk,
mode: editing ? ('EDITING' as const) : ('VIEWING' as const),
fieldName,
}
const heartbeat = () => {
void renewPresence(input)
.then((snapshot) =>
let active = true
let visible = document.visibilityState !== 'hidden'
let connectionId: string | undefined
let sequence = 0
let generation = 0
let opening = false
const heartbeat = async (rejoining = false) => {
if (!active || !visible || opening) return
const requestGeneration = generation
let requestSequence = sequence
try {
if (!connectionId) {
opening = true
const lease = await openPresenceLease({ table, pk })
if (!active || !visible || generation !== requestGeneration) {
void leavePresence({ table, pk, connectionId: lease.connectionId, sequence: 1 }).catch(
() => undefined
)
return
}
opening = false
connectionId = lease.connectionId
sequence = 0
}
requestSequence = ++sequence
const snapshot = await renewPresence({
table,
pk,
connectionId,
sequence: requestSequence,
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
fieldName: focus.current.fieldName,
})
if (active && visible && generation === requestGeneration && sequence === requestSequence) {
setRenewalFailed(false)
cache.setQueryData(queryKeys.collaborationResource(table, pk ?? '', 'presence'), snapshot)
)
.catch(() => undefined)
}
} catch (error) {
if (!active || !visible || generation !== requestGeneration || sequence !== requestSequence)
return
opening = false
setRenewalFailed(true)
if (
isAxiosError(error) &&
error.response?.status === 409 &&
error.response.data?.code === 'PRESENCE_LEASE_EXPIRED'
) {
connectionId = undefined
generation++
if (!rejoining) void heartbeat(true)
}
}
}
heartbeat()
const timer = setInterval(heartbeat, 20000)
const hide = () => beaconPresence(input)
refresh.current = () => {
void heartbeat()
}
void heartbeat()
const timer = setInterval(() => {
void heartbeat()
}, 20000)
const release = (beacon: boolean) => {
generation++
opening = false
const previous = connectionId
connectionId = undefined
if (!previous) return
const input = { table, pk, connectionId: previous, sequence: ++sequence }
if (beacon)
beaconPresence({
...input,
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
fieldName: focus.current.fieldName,
})
else void leavePresence(input).catch(() => undefined)
}
const hide = () => {
visible = false
release(true)
}
const show = () => {
visible = document.visibilityState !== 'hidden'
void heartbeat()
}
const visibility = () => (document.visibilityState === 'hidden' ? hide() : show())
window.addEventListener('pagehide', hide)
window.addEventListener('pageshow', show)
document.addEventListener('visibilitychange', visibility)
return () => {
active = false
release(false)
refresh.current = () => undefined
clearInterval(timer)
window.removeEventListener('pagehide', hide)
window.removeEventListener('pageshow', show)
document.removeEventListener('visibilitychange', visibility)
}
}, [connectionId, table, pk, editing, fieldName, enabled, ended, cache])
}, [table, pk, enabled, ended, cache])
useEffect(() => {
if (!enabled || ended) return
return () => {
void leavePresence({ connectionId, table, pk }).catch(() => undefined)
}
}, [connectionId, table, pk, enabled, ended])
return { ...query, viewers: otherViewers(query.data?.viewers ?? [], userId) }
refresh.current()
}, [editing, fieldName])
return {
...query,
unavailable: enabled && !ended && (query.isError || renewalFailed),
viewers:
enabled && !ended && !query.isError && !renewalFailed
? otherViewers(query.data?.viewers ?? [], userId)
: [],
}
}
+17
View File
@@ -122,6 +122,23 @@ it('invalidates records, panel counts, tasks, notifications and resync queries',
invalidateRealtime(cache, { event: 'resync', data: {} })
expect(invalidate).toHaveBeenLastCalledWith({ queryKey: queryKeys.all() })
})
it('presence hints invalidate only their record or table room without trusting viewer payloads', () => {
const cache = new QueryClient()
const invalidate = vi.spyOn(cache, 'invalidateQueries')
const recordKey = queryKeys.collaborationResource('person', '1', 'presence')
cache.setQueryData(recordKey, { viewers: [] })
invalidateRealtime(cache, {
event: 'presence.invalidated',
data: { table: 'person', pk: 1, viewers: [{ userId: 'untrusted' }] },
})
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: recordKey })
expect(cache.getQueryData(recordKey)).toEqual({ viewers: [] })
invalidate.mockClear()
invalidateRealtime(cache, { event: 'presence.invalidated', data: { table: 'person' } })
expect(invalidate).toHaveBeenCalledExactlyOnceWith({
queryKey: queryKeys.collaborationResource('person', '', 'presence'),
})
})
class Bus {
static peers = new Set<Bus>()
onmessage?: (event: MessageEvent) => void
+5
View File
@@ -41,6 +41,11 @@ export function invalidateRealtime(client: QueryClient, { event, data }: Realtim
}
if (event === 'task.changed') invalidate(queryKeys.collaborationMyWork())
if (typeof data.table !== 'string') return
if (event === 'presence.invalidated') {
// Hints contain no viewer state; reread through the authenticated presence API.
invalidate(queryKeys.collaborationResource(data.table, String(data.pk ?? ''), 'presence'))
return
}
if (event === 'record.changed' || event === 'record.deleted' || event === 'table.changed')
invalidate(queryKeys.tableRecords(data.table))
if (data.pk === undefined || data.pk === null) return
+27 -4
View File
@@ -601,6 +601,8 @@ const viewers = [
{ userId: 'sam', displayName: 'Sam Parker', mode: 'EDITING', fieldName: 'firstName' },
{ userId: 'alex', displayName: 'Alex Rivera', mode: 'VIEWING' },
]
// Demo peers are fixed fixtures; only the current browser's issued handles are tracked.
const presenceLeases = new Map<string, { table: string; pk?: string }>()
const taskStore: import('@/types/collaboration').CollaborationTaskV1[] = [
{
id: 8001,
@@ -671,11 +673,28 @@ collaborationHandlers.push(
const params = new URL(request.url).searchParams
return HttpResponse.json({ table: params.get('table'), pk: params.get('pk'), viewers })
}),
http.post(`${BASE}/presence`, async ({ request }) => {
const input = (await request.json()) as Record<string, unknown>
http.post(`${BASE}/presence/leases`, async ({ request }) => {
const target = (await request.json()) as { table: string; pk?: string }
const connectionId = `lease:${crypto.randomUUID()}`
presenceLeases.set(connectionId, target)
return HttpResponse.json({ connectionId })
}),
http.post(`${BASE}/presence/leases/renew`, async ({ request }) => {
const input = (await request.json()) as {
table: string
pk?: string
connectionId: string
}
const target = presenceLeases.get(input.connectionId)
if (!target || target.table !== input.table || target.pk !== input.pk)
return HttpResponse.json({ code: 'PRESENCE_LEASE_EXPIRED' }, { status: 409 })
return HttpResponse.json({ table: input.table, pk: input.pk, viewers })
}),
http.delete(`${BASE}/presence`, () => HttpResponse.json({ success: true })),
http.delete(`${BASE}/presence`, async ({ request }) => {
const input = (await request.json()) as { connectionId: string }
presenceLeases.delete(input.connectionId)
return HttpResponse.json({ success: true })
}),
http.get(`${BASE}/people`, ({ request }) => {
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? ''
return HttpResponse.json({
@@ -770,7 +789,11 @@ collaborationHandlers.push(
completedByDisplayName: nameOf('me'),
})
if (input.status === 'OPEN')
Object.assign(task, { completedAt: null, completedByUserId: null, completedByDisplayName: null })
Object.assign(task, {
completedAt: null,
completedByUserId: null,
completedByDisplayName: null,
})
publishCollaborationEvent('task.changed', {
table: task.tableName,
pk: task.recordId,
+1
View File
@@ -597,6 +597,7 @@ export interface PresenceViewer {
}
/** Presence target and client lease identity. */
export interface PresenceTarget {
sequence?: number
connectionId: string
table: string
pk?: string
@@ -123,6 +123,10 @@ test.describe('command palette and search', () => {
await expect(pageLinks.filter({ hasText: /^Pet/ })).toHaveText([/^Pet(?! )/, /^Pet Species/, /^Pet Note/])
for (const text of await pageLinks.allTextContents()) expect(text).toMatch(/pet/i)
}
// Help results load above Pages and can move the link during a pointer gesture.
// Wait for the actual result, including after reload, before testing navigation.
await expect(page.locator('[data-qqq-id="help-search-results"] [data-qqq-id="help-result-table:petNote;slot:help"]')).toBeVisible()
await expect(page.locator('[data-qqq-id="help-search-results"]').getByRole('status')).toHaveCount(0)
await page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /^Pet Note/ }).click()
await expect(page).toHaveURL(/\/app\/petNote\/?$/)
expect(searchStatuses.length).toBeGreaterThan(0)
@@ -136,10 +140,12 @@ test.describe('command palette and search', () => {
await page.locator('[data-qqq-id="button-search"]').click()
const dialog = page.getByRole('dialog', { name: 'Command palette' })
await dialog.getByRole('combobox').fill('clone')
const clone = dialog.getByRole('option', { name: /Clone People/ })
// Help arrives asynchronously and can share the process label; it is not a local page match.
await expect(dialog.locator('[data-qqq-id="help-result-process:clonePeople;slot:help"]')).toBeVisible()
const clone = dialog.locator('[data-qqq-id="command-item-clonePeople"]')
await expect(clone).toHaveCount(1)
await expect(clone).toContainText('People App')
await expect(dialog.getByRole('option')).toHaveCount(2)
await expect(dialog.locator('[data-qqq-id^="command-item-"]')).toHaveCount(1)
await expect(dialog.locator('[data-qqq-id="command-search-all"]')).toContainText('Search everything for “clone”')
await expectTouchTargets(dialog)
await expect(clone).toHaveAttribute('aria-selected', 'true')
@@ -7,6 +7,7 @@
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
import { EventEmitter } from 'node:events'
import path from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
@@ -39,7 +40,7 @@ vi.mock('node:child_process', () => {
// Existing stale export and build files cannot determine the browser's selected server.
vi.mock('node:fs', async (original) => {
const fs = {
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
readdirSync: () => ['Fixture.java'],
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api', 'qa'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
@@ -19,9 +19,25 @@ import path from 'node:path'
import { test, expect, type Page } from '@playwright/test'
async function openRecord(page: Page) {
const opened = page.waitForResponse(
(r) => r.url().endsWith('/presence/leases') && r.request().method() === 'POST'
)
const renewed = page.waitForResponse(
(r) => r.url().endsWith('/presence/leases/renew') && r.request().method() === 'POST'
)
await page.goto('/app/person/1')
expect((await opened).status()).toBe(200)
const renewal = await renewed
expect(renewal.status()).toBe(200)
expect(renewal.request().postDataJSON()).toMatchObject({
table: 'person',
pk: '1',
mode: 'VIEWING',
connectionId: expect.stringMatching(/^lease:/),
})
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toHaveText('Alice Johnson')
await expect(page.locator('[data-qqq-id="presence-sam"]')).toBeVisible()
await expect(page.getByText('Presence temporarily unavailable', { exact: true })).toHaveCount(0)
}
async function concurrentEdit(page: Page) {
await page.evaluate(async () => {
+5 -3
View File
@@ -49,10 +49,12 @@ it('keeps the variant build, static assets and entrypoint after the main build d
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe(path.join('.pnpm', 'example'))
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
if (process.platform !== 'win32') {
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
}
})
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {
+5 -1
View File
@@ -37,7 +37,11 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
".next/dev/types/**/*.ts",
".next-collaboration/types/**/*.ts",
".next-collaboration/dev/types/**/*.ts",
".next-presence-live/types/**/*.ts",
".next-presence-live/dev/types/**/*.ts"
],
"exclude": [
"node_modules"