Compare commits

...
Author SHA1 Message Date
james.maesandCodex ecbe9fcc6e fix: include pinned patches in dashboard dependency layer
Both image jobs failed because the dependency install could not read the Next patch. Copy patches before the frozen install. Corrected Linux frontend image stage builds locally; Linux static export also verified78segment files in CI.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 16:44:56 -05:00
james.maesandCodex 4f9eb7e0ff fix: produce portable static export segment paths
Use a native Node export launcher and pin a two-line Next exporter patch that normalizes Windows separators before prefetch filename encoding. Verify every public prerendered segment and its bytes after export. Windows export passes78segment checks; Linux and browser CI remain pending.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 16:40:03 -05:00
james.maesandCodex 655a1850f6 fix: keep search navigation stable as help loads
Place asynchronous help after existing navigation targets in the palette and search page. Add a controlled pointer-down/up regression for both surfaces.

Refs QRun-IO/qqq#1310

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 15:50:22 -05:00
james.maesandCodex 3f9a9c6e48 ci: use verified HTTPS package mirror fallback
Pin centralized workers to the tested Azure mirror bypass after the prior acceptance run completed.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 14:55:30 -05:00
james.maesandCodex 160cc46bd3 ci: bound browser dependency downloads for RC validation
Pin the shared workflows to the verified package retry patch in qqq-orb PR159. Keep the paired core acceptance revision and all browser/test gates.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 14:32:18 -05:00
james.maesandCodex f09687cf65 merge: retain current frontend CI for collaboration candidate
Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 13:42:42 -05:00
james.maesandCodex 83755e4969 ci: pin collaboration acceptance to coordinated core
Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 13:41:31 -05:00
james.maesandCodex 5a9ccc63a6 chore: prepare coordinated collaboration frontend RC.37
Co-Authored-By: Codex <noreply@openai.com>
2026-10-22 13:26:30 -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
40 changed files with 5139 additions and 3547 deletions

No files matched your search

+5 -4
View File
@@ -24,13 +24,14 @@ 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@3d3d0c60f4df6b01b0a502d0e62c395c92e4a09c
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@3d3d0c60f4df6b01b0a502d0e62c395c92e4a09c
with:
qqq_ref: ${{ inputs.qqq_ref || '' }}
# Keep PR, main and tag acceptance on the core paired with this lease protocol.
qqq_ref: ${{ inputs.qqq_ref || '33464d37e815e61dc0a08bd5b2eb249718dfe1b5' }}
dashboard-image:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && startsWith(github.ref,
@@ -38,4 +39,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@3d3d0c60f4df6b01b0a502d0e62c395c92e4a09c
+2
View File
@@ -10,6 +10,8 @@
# next.js
/.next/
/.next-collaboration/
/.next-presence-live/
/out/
# production
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
+15
View File
@@ -0,0 +1,15 @@
# Collaboration frontend candidate
This branch prepares 1.0.0-RC.37 for the coordinated collaboration adoption. A version declaration is not publication evidence; verify the remote artifact before adopting it.
Runtime code is the previously accepted collaboration frontend at 8d372142. The later dce92cfb commit adds a focused mention-read UI assertion. This branch changes release metadata, not that runtime behavior.
Use QQQ core `4.1.0-collab-33464d37e-SNAPSHOT` and collaboration `0.1.0-collab-8f348da-SNAPSHOT` together. The issued presence lease protocol requires coordinated frontend/backend adoption. Redis-backed hosts additionally use `qbit-redis:0.1.0-redis-7fe5492-SNAPSHOT`. Presence does not replace versioned database save checks.
Acceptance must select core source `33464d37e815e61dc0a08bd5b2eb249718dfe1b5`. Previous runtime acceptance is GitHub run 39683690943; a new run will verify this release revision before publication. The canonical CircleCI frontend publisher validates matching npm/Maven/tag versions and runs type, lint, test and static-export checks.
Track host adoption and release evidence in QRun-IO/qqq#1315. This candidate is not a production deployment or a stable release.
The caller workflow pins the paired core for PR, main and tag acceptance; an explicit dispatch input can test another revision. This avoids the repository-wide `QQQ_ACCEPTANCE_REF=develop` default selecting a different backend during release checks. Update this pin deliberately with the next coordinated backend adoption.
Publish through the documented release flow: reviewed merge to main, then a signed version tag on that main revision. Do not tag the preparation branch or use a green dispatch as a substitute for the required PR checks.
+20
View File
@@ -0,0 +1,20 @@
# Windows static export
Issue: QRun-IO/qqq#1309.
The package command used POSIX inline environment assignment, which Windows cmd could not execute. scripts/build-export.mjs now starts the installed Next CLI with the same export/mock-API settings through Node. It forwards build arguments and returns a failing status when the child build or integrity check fails.
A separate Next16.3.8 exporter defect caused nested segment paths on Windows. collectSegmentPaths returns native relative paths; the exporter passed those directly to a filename encoder that replaces only forward slashes. For example, Preferences was written into nested directories rather than the flat __next.!KGRhc2hib2FyZCk.app.preferences.__PAGE__.txt path the browser requests.
The pinned pnpm patch normalizes separators before filename encoding in both Next CommonJS and ESM exporters. It changes two lines and adds no dependency version upgrades. The patch hash and peer references are recorded in the lockfile. Revisit this patch on a Next upgrade; remove it only when the upstream package passes the same Windows regression.
Every successful build:export now checks all public prerendered routes. Each generated segment must have its expected browser-addressable output filename, and its bytes must equal the source segment. Next explicitly skips the internal /_global-error component during static export; the checker mirrors that exclusion. It does not skip public routes or suppress browser diagnostics.
Verification on Windows Node24.22.0:
- Original package command fails before Next starts.
- Clean native-variable export reproduces25 missing public-route fragments (plus3 internal error files initially included by the diagnostic).
- Patched clean export produces all78 public-route segment files with exact source bytes.
- Complete pnpm build:export command exits0 with the integrated integrity check.
- Script syntax and git diff checks pass.
Linux export parity passed at4f9eb7e0: acceptance build job120105016937 ran the complete export and verified all78 public segment paths and bytes. Browser acceptance remains in progress. Both dashboard-image jobs exposed a missing Docker COPY for the patch directory before dependency installation; the follow-up adds that dependency-layer input. The corrected Linux Docker frontend stage builds successfully locally, including frozen dependency installation and the standalone production build; both architecture jobs still require remote revalidation. The existing RC37 acceptance result belongs to its earlier unpatched head and is not claimed for this change.
+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
+9 -4
View File
@@ -1,12 +1,12 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.36",
"version": "1.0.0-RC.37",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build:export": "QQQ_NEXT_OUTPUT=export NEXT_PUBLIC_MOCK_API=false next build",
"build:export": "node scripts/build-export.mjs",
"build:jar": "pnpm build:export && mvn -B -q clean package",
"start": "next start",
"lint": "eslint src && node scripts/check-license-headers.mjs",
@@ -104,6 +104,7 @@
"storybook": "10.6.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "7.3.7",
"vitest": "^4.1.11"
},
"msw": {
@@ -116,10 +117,14 @@
"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"
]
],
"patchedDependencies": {
"next@16.3.8": "patches/next@16.3.8.patch"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
diff --git a/dist/esm/export/index.js b/dist/esm/export/index.js
index 6c35f4f47f704caea869a4f89721e0fc71f7f458..e6cb137651229c7f4e1d8c355391c0d96903e998 100644
--- a/dist/esm/export/index.js
+++ b/dist/esm/export/index.js
@@ -717,7 +717,7 @@ async function exportAppImpl(dir, options, span, staticWorker) {
const segmentsDirDest = join(outDir, unnormalizedRoute);
const segmentPaths = await collectSegmentPaths(segmentsDir);
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
- const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length);
+ const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length).split(sep).join('/');
const segmentFilename = convertSegmentPathToStaticExportFilename(segmentPath);
const segmentFileDest = join(segmentsDirDest, segmentFilename);
await fs.mkdir(dirname(segmentFileDest), {
diff --git a/dist/export/index.js b/dist/export/index.js
index a19941bec99b5c5673834872eaf8a95deb584a34..0a02e470ea21c8c29a08f01b70d7584040678b1b 100644
--- a/dist/export/index.js
+++ b/dist/export/index.js
@@ -785,7 +785,7 @@ async function exportAppImpl(dir, options, span, staticWorker) {
const segmentsDirDest = (0, _path.join)(outDir, unnormalizedRoute);
const segmentPaths = await collectSegmentPaths(segmentsDir);
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
- const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
+ const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length).split(_path.sep).join('/');
const segmentFilename = (0, _segmentvalueencoding.convertSegmentPathToStaticExportFilename)(segmentPath);
const segmentFileDest = (0, _path.join)(segmentsDirDest, segmentFilename);
await _fs.promises.mkdir((0, _path.dirname)(segmentFileDest), {
+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,
},
+50 -41
View File
@@ -9,6 +9,12 @@ 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
patchedDependencies:
next@16.3.8:
hash: 7lgylyvrg3k4lckqgmpbjdieaa
path: patches/next@16.3.8.patch
importers:
@@ -100,7 +106,7 @@ importers:
version: 0.485.0(react@19.2.4)
next:
specifier: 16.3.8
version: 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)
version: 16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rapidoc:
specifier: 10.1.0
version: 10.1.0
@@ -155,7 +161,7 @@ importers:
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
'@storybook/nextjs-vite':
specifier: 10.6.0
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.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))
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(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@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.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)
@@ -188,7 +194,7 @@ 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)
@@ -234,9 +240,12 @@ importers:
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))
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.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
packages:
@@ -3275,8 +3284,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: '*'
@@ -5093,8 +5102,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:
@@ -5926,11 +5935,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
@@ -6738,11 +6747,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.0(storybook@10.6.0(@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)
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 +6759,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.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(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@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.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/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.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.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))
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)
'@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.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(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4)
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(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.0(next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@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.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)
@@ -6781,11 +6790,11 @@ snapshots:
'@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.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.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/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.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.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)
empathic: 2.1.0
magic-string: 1.4.2
@@ -6795,7 +6804,7 @@ snapshots:
resolve: 1.22.12
storybook: 10.6.0(@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:
@@ -7252,7 +7261,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,7 +7269,7 @@ 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
@@ -7276,7 +7285,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.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.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:
@@ -7295,14 +7304,14 @@ snapshots:
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.11(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
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:
@@ -7339,7 +7348,7 @@ snapshots:
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.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.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:
@@ -7526,7 +7535,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 +8337,7 @@ snapshots:
flatted@3.4.4: {}
follow-redirects@1.16.0: {}
follow-redirects@1.16.1: {}
for-each@0.3.5:
dependencies:
@@ -9444,7 +9453,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
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):
next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.3.8
'@swc/helpers': 0.5.23
@@ -10619,37 +10628,37 @@ 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.0(next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@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.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)
magic-string: 0.30.21
module-alias: 2.3.4
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)
next: 16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pathe: 2.0.3
probe-image-size: 7.4.0
semver: 7.8.5
storybook: 10.6.0(@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,10 +10674,10 @@ 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.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.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/mocker': 4.1.11(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.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -10685,7 +10694,7 @@ 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
+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>
+25
View File
@@ -0,0 +1,25 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Set export options without platform-specific shell assignment syntax.
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const build = spawn(process.execPath, [require.resolve('next/dist/bin/next'), 'build', ...process.argv.slice(2)], {
stdio: 'inherit',
env: { ...process.env, QQQ_NEXT_OUTPUT: 'export', NEXT_PUBLIC_MOCK_API: 'false' },
})
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => build.kill('SIGTERM'))
build.on('error', (error) => {
console.error(error.message)
process.exitCode = 1
})
build.on('exit', async (code) => {
if (code !== 0) process.exit(code ?? 1)
await import('./check-export-segments.mjs')
})
+45
View File
@@ -0,0 +1,45 @@
/*
* 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 assert from 'node:assert/strict'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
const dist = process.env.QQQ_NEXT_DIST_DIR || '.next'
const manifest = JSON.parse(readFileSync(path.join(dist, 'prerender-manifest.json'), 'utf8'))
let checked = 0
const missing = []
/** Return every generated segment beneath a route's source directory. */
function segments(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const filename = path.join(directory, entry.name)
return entry.isDirectory() ? segments(filename) : entry.name.endsWith('.segment.rsc') ? [filename] : []
})
}
for (const [route, metadata] of Object.entries(manifest.routes)) {
// Next explicitly excludes this internal error component from static export.
if (route === '/_global-error') continue
if (!metadata.dataRoute?.endsWith('.rsc')) continue
const directory = path.join(dist, 'server/app', metadata.dataRoute.slice(1, -4) + '.segments')
assert.ok(existsSync(directory), 'Missing generated segments for ' + route)
for (const source of segments(directory)) {
const relative = path.relative(directory, source).split(path.sep).join('/')
const filename = '__next.' + relative.slice(0, -'.segment.rsc'.length).replaceAll('/', '.') + '.txt'
const destination = path.join('out', route.slice(1), filename)
if (!existsSync(destination)) {
missing.push(destination)
continue
}
assert.deepEqual(readFileSync(destination), readFileSync(source), 'Export bytes differ: ' + destination)
checked++
}
}
assert.ok(checked > 0, 'No exported segments were checked')
assert.equal(missing.length, 0, 'Missing browser-addressable segment files:\n' + missing.join('\n'))
console.log('Verified ' + checked + ' browser-addressable segment files and their exact bytes')
+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>
)
}
+2 -1
View File
@@ -334,7 +334,6 @@ export function CommandMenu({ open, onClose, navTargets, metaData, helpOnly: ope
{searchEnabled && <>
{!search && <Command.Item value="help:search" data-qqq-id="command-search-help" className={ITEM_CLASS} onSelect={() => { setHelpOnly(true); dialogRef.current?.querySelector('input')?.focus() }}>Search help…</Command.Item>}
<HelpSearchResults search={search} palette onSelect={onClose} />
</>}
{!helpOnly && screen && visibleActions.length > 0 && (
<Command.Group heading={<GroupHeading>{screen.table.label} Actions</GroupHeading>} data-qqq-id="command-group-actions">
@@ -442,6 +441,8 @@ export function CommandMenu({ open, onClose, navTargets, metaData, helpOnly: ope
</Command.Item>
</Command.Group>
)}
{/* Async help results must not move navigation targets during a click. */}
{searchEnabled && <HelpSearchResults search={search} palette onSelect={onClose} />}
</Command.List>
{/* Footer hint */}
+2 -1
View File
@@ -511,7 +511,6 @@ export function SearchPage() {
<div className={cn(showSplit && 'grid grid-cols-[minmax(0,1fr)_minmax(24rem,34rem)] items-start gap-6')}>
{/* delegated click: remembers the search when a result link is followed; ↑/↓ move between results */}
<div className="min-w-0 space-y-5" onClick={rememberOnOpen} onKeyDown={handleResultsKeyDown}>
<HelpSearchResults search={term} />
{view !== 'pages' && !singleTable && pages.length > 0 && (
<PagesSection pages={pages.slice(0, OVERVIEW_PAGES)} term={term} moreHref={pages.length > OVERVIEW_PAGES ? filterHref({ view: 'pages' }) : undefined} total={pages.length} />
)}
@@ -595,6 +594,8 @@ export function SearchPage() {
</ul>
</section>
)}
{/* Keep delayed help below already-clickable page and record results. */}
<HelpSearchResults search={term} />
</div>
{showSplit && previewed && (
<aside
@@ -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)
@@ -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 () => {
+54
View File
@@ -0,0 +1,54 @@
/*
* 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 at https://www.apache.org/licenses/LICENSE-2.0
*/
// Regression: delayed help must not move a navigation target during a pointer gesture.
import { test, expect } from '@playwright/test'
import { setupApiMocks } from './api-mocks'
for (const surface of ['palette', 'search page'] as const) {
test(`${surface}: a page remains clickable when help arrives between pointer down and up`, async ({ page, baseURL }) => {
await setupApiMocks(page)
await page.route('**/qqq/v1/version', (route) => route.fulfill({ json: {
apiVersion: '1.4.0', features: ['help'], extensions: [],
} }))
let releaseHelp!: () => void
const held = new Promise<void>((resolve) => { releaseHelp = resolve })
await page.route('**/qqq/v1/help/search?**', async (route) => {
await held
await route.fulfill({ json: { results: [{
kind: 'table', key: 'table:person;slot:help', name: 'person',
label: 'People help', summary: 'Delayed help result', text: 'People help content',
}] } })
})
try {
const requested = page.waitForRequest('**/qqq/v1/help/search?**')
const origin = baseURL!.replace('127.0.0.1', 'localhost')
if (surface === 'palette') {
await page.goto(`${origin}/app`)
await page.locator('[data-qqq-id="button-search"]').click()
await page.locator('[data-qqq-id="command-menu-search"]').fill('People')
} else {
await page.goto(`${origin}/app/search?q=People&view=pages`)
}
await requested
const target = surface === 'palette'
? page.locator('[data-qqq-id="command-item-person"]')
: page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /People/ })
await expect(target).toBeVisible()
const box = await target.boundingBox()
expect(box).not.toBeNull()
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.mouse.down()
releaseHelp()
await expect(page.locator('[data-qqq-id="help-result-table:person;slot:help"]')).toBeVisible()
expect(await target.boundingBox()).toEqual(box)
await page.mouse.up()
await expect(page).toHaveURL(/\/app\/person\/?$/)
} finally {
releaseHelp()
}
})
}
+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"